En septembre, j'ai passé une dizaine de jours, avec Claude, à optimiser le lecteur de combat. Sur les gros combats (une battle royale à 30 poireaux, un combat chargé en effets), l'animation manquait de fluidité, surtout sur les machines modestes. Et dulph avait signalé sur le forum un problème plus précis : sous Firefox, l'animation se figeait environ une seconde à chaque explosion (bazooka, lance-grenades, météorite, trébuchet), puis reprenait avec plusieurs actions de retard.
Voici ce qu'on a trouvé, ce qui a marché, et ce qui n'a rien donné.
| | Avant | Après | |---|---|---| | Combat à 8 poireaux et 71 effets, processeur ralenti ×6 | 12 à 19 img/s | 55 à 60 img/s | | Même combat, processeur ralenti ×12 | 7 à 10 img/s | près de 40 img/s | | Battle royale à 30 poireaux, processeur ralenti ×4 | 20 img/s | 53 img/s | | Même combat, à-coups (p99) | 244 ms | 31 ms | | Explosions sous Firefox | gel d'environ une seconde | plus de gel |
Le « processeur ralenti » est une option des outils de développement de Chrome qui simule une machine plus lente : c'est ce qui permet de voir sur un PC récent ce que vit un joueur sur un vieux portable. Le p99 est la durée sous laquelle tombent 99 images sur 100. C'est lui qui mesure les à-coups qu'on ressent, alors que la moyenne les noie.
Une bonne partie du travail a consisté à construire des mesures fiables :
Et quelques règles, apprises en se trompant :
Premier constat, et le plus important : avec le processeur ralenti ×6, dessiner le terrain, les poireaux et les effets ne prenait que 1,4 ms par image. Le reste du temps partait dans Vue, le framework de l'interface.
Le moteur du lecteur était rangé dans un objet réactif de Vue (un ref()). Chaque lecture d'une propriété passait par un proxy, et chaque écriture (position, vie, compteur d'animation, des milliers par image) programmait un nouveau rendu de l'interface.
Le moteur est maintenant hors de la réactivité (shallowRef(markRaw(...))). L'interface se met à jour sur un compteur que le moteur incrémente, avec une régulation automatique : on mesure ce que coûte chaque rendu de l'interface, et on les espace pour qu'ils ne prennent qu'une petite part du temps. Sans cette régulation, un rafraîchissement fixe à 20 par seconde faisait pire qu'avant sur une machine très lente (7 img/s contre 10).
Deux pièges au passage :
La boucle d'animation a aussi changé. C'était un setTimeout de 16,7 ms lancé après le travail de chaque image, ce qui donnait environ 50 images par seconde sur un écran à 60 Hz, sans être calé sur l'écran. Elle passe maintenant par requestAnimationFrame, avec une limite à 80 images par seconde pour ne pas dessiner 144 fois par seconde sur les écrans rapides. Le seuil doit rester sous deux rafraîchissements d'un écran à 144 Hz (13,9 ms) : à 70, on sautait deux images sur trois et on tombait à 48. Dans un onglet caché, le combat se met maintenant en pause.
Sur le combat à 8 poireaux : 47 → 54 img/s à pleine vitesse, de 12-19 à 40-50 img/s avec le processeur ralenti ×6, de 7-10 à 23-29 img/s à ×12, et des à-coups divisés par 4 à 5.
Sur la battle royale, le lecteur se figeait encore quatre fois par seconde. Dans chaque image longue, 74 ms partaient dans le rendu de l'interface : les 30 vignettes de l'ordre de jeu et les 17 barres de vie, avec leurs infobulles, étaient reconstruites en bloc, alors qu'entre deux images un seul poireau change de vie. Chaque entrée est maintenant mémoïsée (v-memo) sur les valeurs qu'elle affiche.
Le défaut le plus instructif était dans le journal de combat. Dans Vue, quand une boucle v-for est posée sur un `, sa clé doit être sur le lui-même. Elle était sur ses enfants, donc Vue comparait la liste position par position. Comme la fenêtre du journal glisse d'une ligne à chaque action, toutes les positions changeaient de contenu, et toutes les lignes affichées étaient reconstruites à chaque nouvelle ligne, plusieurs fois par seconde. Déplacer la clé a suffi : p99 79,8 → 44,7 ms. Le même défaut existait dans quatre autres listes du site (atelier, aide des objets, statistiques, marché). La règle ESLint prévue pour l'attraper ignore justement les enfants qui portent un v-if`, c'est-à-dire exactement ce cas.
Le rendu de l'interface partait aussi à la fin de la même tâche que le dessin du terrain, donc une image coûtait les deux. Il passe maintenant dans une tâche à part : l'image du terrain est affichée à l'heure, et l'interface est mise à jour juste après.
On a aussi introduit une régression, vite corrigée. Pour réduire le coût du journal, on avait d'abord limité sa fenêtre à 80 lignes au lieu de 250. Ça soignait le symptôme (la vraie cause était la clé mal placée) et ça cassait le défilement : en remontant le journal, on atteignait le haut de la fenêtre en cinq crans de molette, la fenêtre glissait, la barre de défilement se repositionnait, et ça recommençait sans fin. La fenêtre est revenue à 250 lignes, et la mesure confirme qu'elles ne coûtent plus rien (58,1 contre 57,9 img/s).
Sur la battle royale, avec le processeur ralenti ×4 : de 20 à 53 img/s, et un p99 de 244 à 31 ms.
Une fois Vue remis à sa place, le dessin devenait la plus grosse part du temps d'image. Pour savoir où il partait, on fige une image et on la redessine des centaines de fois en retirant un élément à la fois : ce qui disparaît du temps de dessin, c'est ce que coûtait l'élément. Sur le combat à 71 effets, les icônes des effets prenaient 39 % du dessin, leurs textes 20 %, les ombres 9 % et les plaques de nom 4 %. Une seule image comptait 267 drawImage, 160 fillText et 151 measureText.
J'avais quelques idées en vrac pour accélérer le dessin, dont celle-ci : fusionner les effets avec leurs textes. Une pastille d'effet (icône de la puce ou de l'arme, valeur, durée) ne change qu'à l'ajout, au cumul ou au retrait d'un effet, mais elle était repeinte entièrement à chaque image : une image de 250 pixels réduite à 18, deux fonds, deux textes mesurés. Elle est maintenant peinte une seule fois, à la taille exacte où elle s'affiche, et gardée sous forme d'ImageBitmap : à chaque image, il ne reste qu'une copie par effet. Avec le processeur ralenti ×6, les 71 pastilles passent de 3,2 à 0,7 ms par image, et le dessin complet de 5,8 à 3,3 ms.
La première version sortait plus floue, avec 13 % de netteté en moins. La cause : deux décalages d'un demi-pixel. Une image recopiée à l'échelle 1 mais à cheval entre deux pixels est entièrement mélangée avec ses voisins. Une fois les pastilles calées sur la grille de pixels du canvas, la netteté revient au niveau du dessin direct (74,6 contre 75,8). Pour la mesurer, on a utilisé « l'énergie de gradient », la somme des écarts entre pixels voisins, qui s'effondre quand une image est floue. Une simple différence de pixels ne distingue pas « décalé d'un pixel » de « flou ».
Les pastilles agrandies ×5. En haut, la première version ; en bas, la version calée sur les pixels.
Deux autres pièges : une image encore en cours de chargement passe le test « peut-on la dessiner ? » sans rien peindre, et la pastille préparée à ce moment-là restait vide pendant tout le combat ; et vérifier que la police était chargée, pour chacune des 71 pastilles, annulait à lui seul tout le gain.
Le même principe a servi ensuite pour les plaques de nom : fond, nom, barre de vie et pastilles sont préparés en une seule image. Le texte n'y coûtait presque rien (environ 5 µs par plaque). Ce qui coûtait, c'est le nombre d'appels : une douzaine par plaque et par image. Sous Chrome ralenti ×4, avec 20 plaques, le dessin passe de 4,4 à 2,9 ms par image. Une plaque n'est figée qu'après deux images identiques : pendant le défilement des PV après un coup ou pendant un déplacement, elle est dessinée directement, comme avant.
Firefox était nettement plus lent que Chrome sur le lecteur : à quota de processeur égal, il produisait environ deux fois moins d'images, et quatre fois moins quand le quota était très bas. Pour comprendre, on a mesuré dans la page le coût d'un drawImage :
| Opération | Firefox | Chrome | |---|---|---| | Image tournée (une main, une arme) | 60 à 85 µs | 8 µs | | Image redimensionnée | 10 à 27 µs | 2 µs | | Copie 1:1 alignée sur les pixels | 2,5 µs | 1 à 2 µs |
Sous Firefox, une image tournée coûte des dizaines de fois plus qu'une copie alignée. Et en recensant tous les dessins d'une image de la battle royale, on trouvait 199 copies alignées, 70 images redimensionnées et 86 images tournées, dont 41 ombres, 30 mains et 15 armes.
Les mains. Une main de poireau est un disque : la faire tourner avec l'arme ne change pas un pixel. Elle était pourtant dessinée dans le repère tourné de l'arme. Elle est maintenant posée hors de ce repère, alignée, à sa taille exacte. On ne pouvait pas simplement annuler la rotation par une rotation inverse : un poireau tourné vers la gauche est dessiné en miroir, et un miroir combiné à une rotation ne se défait pas par la rotation inverse. La position de la main est donc lue directement dans la matrice de transformation.
L'arme et ses mains. Deuxième idée de ma liste : cuire les mains sur l'arme. L'angle de visée ne change qu'au moment d'une attaque : sur 15 secondes de combat, on a compté 13 573 dessins d'arme tournés pour seulement 195 silhouettes différentes. L'arme et ses deux mains sont donc dessinées une fois, déjà tournées, dans une image qu'on recopie ensuite d'un seul coup, alignée. Trois dessins tournés sont remplacés par une copie.
La première version a été mesurée plus lente et plus floue, et on a failli abandonner l'idée. Les deux défauts venaient de l'implémentation :
willReadFrequently), qui le place en mémoire processeur avec un filtrage différent de celui du canvas du jeu. Sans elle, l'arme est aussi nette qu'en dessin direct ;Résultat : +2 % d'images par seconde sous Chrome ralenti ×6, +5 % à ×10, et +4 % sous Firefox. Sous Firefox très bridé (en dessous d'environ 15 img/s), le gain n'apparaissait pas du tout : à ce régime, l'image est dominée par autre chose. Il faut mesurer dans la plage qui correspond aux vraies machines.
Troisième idée de ma liste : fusionner le poireau et son chapeau. Pour la performance, ça n'aurait rien changé. Le corps d'un poireau est déjà une image préparée à l'avance (le jeu convertit les SVG une fois pour toutes), et il ne restait que quelques petits dessins de chapeau, alignés, en dessous de ce qu'on sait mesurer.
En revanche, ça a mis le doigt sur un défaut visuel. L'ombre du corps et celle du chapeau étaient deux dessins semi-transparents successifs : là où le chapeau recouvre la tête, les deux se superposaient et l'ombre devenait plus sombre, 80 % d'opacité au lieu de 55 %. Les deux ombres sont maintenant réunies en une seule silhouette avant d'être posées.
L'ombre d'un poireau chapeauté, avant et après.
Les ombres étaient aussi dessinées tournées, une par poireau et par image. Les préparer déjà tournées imposait d'arrêter leur légère « respiration » (elles grandissaient et rétrécissaient de 2,5 % avec le poireau). J'ai d'abord voulu garder ce mouvement, puis j'ai choisi la performance une fois le gain mesuré : les ombres ne respirent plus, et sous Firefox le dessin d'une image de la battle royale passe de 3,35 à 2,55 ms. Même traitement pour l'ombre de l'arme (2,65 → 2,45 ms) et pour les losanges de couleur d'équipe sous les entités (2,70 → 2,35 ms).
Le fond du terrain n'est pas redessiné à chaque image, seulement au lancement, au redimensionnement de la fenêtre et au changement de mode (nuit, tactique). Mais un redessin coûtait 28 à 45 ms sous Chrome ralenti ×4 : une boucle qui découpe les bords des zones (eau, herbe, glace) allait jusqu'à la largeur du canvas en pixels au lieu du nombre de cases, et traçait environ 46 000 losanges hors du terrain. Une fois la boucle bornée, c'est 9 à 32 ms selon la carte, pour un rendu identique au pixel près. Sous Firefox, ça n'a rien changé : il rejette presque gratuitement ce qui est dessiné hors du canvas, alors que Chrome paie chaque appel. Sur ce lecteur, un coût se compte en nombre d'appels sous Chrome, et en pixels dessinés sous Firefox.
Les obstacles étaient posés à des positions fractionnaires, à cheval sur deux pixels, et donc rééchantillonnés à chaque image. Calés sur la grille de pixels, ils sont plus nets, et sous Firefox leur dessin passe de 2,5 à 1,6 ms par image (une copie à cheval coûtait 40 µs, contre 3 µs alignée).
Des obstacles agrandis ×5, avant et après le calage sur les pixels.
En regardant le fond de près, on a aussi trouvé pourquoi la grille prenait la couleur des zones : cyan sur la banquise, bleu sur la plage. Pour peindre une zone, le jeu peint sa texture sur tout le motif, puis retire une par une les cases qui n'en font pas partie. Sur l'arête commune de deux cases retirées, l'anticrénelage de chaque retrait n'enlève qu'une partie des pixels, et il restait jusqu'à un quart de la texture. Les cases sont maintenant retirées en un seul tracé.
La grille sur la banquise, avant et après.
Au passage, un petit obstacle pouvait être dessiné par-dessus un gros obstacle 2×2 placé devant lui. C'est corrigé aussi.
Restait le problème signalé par dulph. Une explosion dessinait environ 345 cercles par image pendant une seconde, chacun avec sa couleur. Une pluie de météorites cumule cinq explosions et leurs traînées, soit plus de 1 700 cercles et 200 flammes dans la même image. Sous Firefox, un cercle coûte environ 5 µs, et une flamme agrandie 15 à 27 µs.
Les boules de feu, les flammes et le gaz sont maintenant préparés sous forme d'images et recopiés alignés. Sous Firefox, le dessin de ces animations coûte environ 70 % de moins pour les explosions, et 75 à 80 % de moins pour les flammes et le gaz. Ce changement est réservé à Firefox : sous Chrome, dont le canvas passe par la carte graphique, ces copies doublaient le temps de l'explosion, il garde donc l'ancien dessin.
dulph a retesté : « C'est visiblement mieux, même si je vois encore des ralentissements », avec un profil Firefox enregistré pendant le gel. Ce profil a été décisif. Le gel ne restait qu'à la première explosion du combat, et le navigateur y attendait la carte graphique : tant que les images des boules de feu n'étaient pas prêtes, le jeu les dessinait à l'ancienne et les préparait en même temps, en plein combat. Tout est maintenant préparé au lancement du combat (environ 20 ms), et plus rien n'est fabriqué pendant les animations. Sur son combat, la première pluie de météorites passe de 481 à 131 ms de dessin au total.
La réponse de dulph : « C'est parfait, aucun ralentissement n'est visible jusqu'ici ». Merci à lui pour ses retours, son profil et sa patience.
Pour suivre tout ça dans le jeu, les administrateurs ont maintenant un compteur d'images dans la barre du lecteur : valeur courante, courbe des 30 dernières secondes, minimum, moyenne, maximum et p99. Il compte les images de chaque demi-seconde au lieu de faire la moyenne des « 1 / durée de l'image », parce que cette moyenne est flatteuse : une image de 10 ms et une de 50 ms font réellement 33 images par seconde, mais 60 si on moyenne les inverses.
Un mode « débridé » fait tourner la boucle sans attendre l'écran, pour voir la marge qui reste au-dessus de 60 images par seconde : sur un combat à deux poireaux, le moteur en produit 429.
Sur le combat à 71 effets, avec un processeur ralenti six fois, le lecteur tient maintenant 55 à 60 images par seconde, contre 12 à 19 avant. La plus grosse partie du gain ne venait pas du dessin mais de l'interface, et on ne l'aurait pas trouvée sans mesurer d'abord. Tout est en ligne depuis le 27 septembre.
Si vous voyez encore des ralentissements dans le lecteur, n'hésitez pas à le signaler sur le forum, en précisant votre navigateur et votre machine : c'est comme ça que le problème de dulph a pu être trouvé.
Impossible de charger les données du jeu.
Vérifiez votre connexion et réessayez.