Comment le portage d'un jeu sur PSVita a amélioré les performances globales

image

Au niveau peut être des milliers d'ennemis.

Defender's Quest: Valley of the Forgotten DX a toujours eu des problèmes de vitesse de longue date, et j'ai finalement réussi à les résoudre. La principale incitation à une augmentation massive de la vitesse était notre port sur la PlayStation Vita . Le jeu était déjà sorti sur PC et fonctionnait bien, sinon parfaitement, sur Xbox One avec la PS4 . Mais sans une amélioration majeure du jeu, nous ne pourrions jamais le lancer sur Vita.

Lorsqu'un jeu ralentit, les commentateurs sur Internet accusent généralement un langage ou un moteur de programmation. Il est vrai que des langages comme C # et Java sont plus chers que C et C ++, et des outils comme Unity ont des problèmes insolubles, comme la récupération de place. En fait, les gens proposent de telles explications parce que la langue et le moteur sont les propriétés les plus évidentes des logiciels. Mais les vrais tueurs de la performance peuvent être de minuscules détails stupides qui n'ont rien à voir avec l'architecture.

0. Outils de profilage


Il n'y a qu'un seul moyen réel d'accélérer le jeu: effectuer le profilage. Découvrez ce que l'ordinateur passe trop de temps et faites-lui passer moins de temps dessus, ou mieux, évitez qu'il ne perde du temps.

L'outil de profilage le plus simple est le moniteur système Windows standard (moniteur de performances):


En fait, c'est un outil assez flexible et il est très facile de travailler avec lui. Appuyez simplement sur Ctrl + Alt + Suppr, ouvrez le "Gestionnaire des tâches" et cliquez sur l'onglet "Performances". Cependant, n'exécutez pas trop d'autres programmes. Si vous regardez attentivement, vous pouvez facilement détecter les pics d'utilisation du processeur et même les fuites de mémoire. C'est un moyen non informatif, mais cela peut être la première étape pour trouver des endroits lents.

Defender's Quest est écrit dans le langage Haxe de haut niveau compilé dans d'autres langages (ma cible principale était le C ++). Cela signifie que tout outil capable de profiler C ++ peut également profiler mon code C ++ généré par Haxe. Donc, quand j'ai voulu comprendre les causes des problèmes, j'ai lancé Performance Explorer à partir de Visual Studio:


De plus, différentes consoles ont leurs propres outils de profilage, ce qui est très pratique, mais à cause du NDA je ne peux rien vous dire à leur sujet. Mais si vous y avez accès, assurez-vous de les utiliser!

Au lieu d'écrire un terrible tutoriel sur la façon d'utiliser des outils de profilage comme Performance Explorer, je laisse simplement un lien vers la documentation officielle et je passe au sujet principal - les choses incroyables qui ont conduit à une énorme augmentation de la productivité et comment j'ai réussi à les trouver !

1. Détection des problèmes


La performance du jeu n'est pas seulement la vitesse elle-même, mais aussi sa perception. Defender's Quest est un jeu de genre de tower defense qui est rendu à 60 FPS, mais avec une vitesse de jeu variable dans la plage de 1 / 4x à 16x. Quelle que soit la vitesse du jeu, la simulation utilise un horodatage fixe avec 60 mises à jour par seconde de 1x temps de simulation. Autrement dit, si vous exécutez le jeu à une vitesse de 16x, la logique de mise à jour fonctionnera en fait avec une fréquence de 960 FPS . Honnêtement, ce sont des demandes trop élevées pour le jeu! Mais c'est moi qui ai créé ce mode, et s'il s'avère lent, les joueurs le remarqueront certainement.

Et dans le jeu, il y a un tel niveau:


C'est la bataille bonus finale "Endless 2", c'est aussi "mon cauchemar personnel". La capture d'écran a été prise en mode New Game +, dans lequel les ennemis sont non seulement beaucoup plus forts, mais ont également des fonctionnalités telles que la restauration de la santé. La stratégie préférée du joueur ici est de pomper les dragons au niveau Roar maximum (attaque AOE qui étourdit les ennemis), et derrière eux a mis un certain nombre de chevaliers avec Knockback pompé au maximum pour pousser tout le monde passant les dragons dans leur zone d'action. L'effet cumulatif est qu'un énorme groupe de monstres reste indéfiniment au même endroit, beaucoup plus longtemps que les joueurs n'auraient à survivre s'ils les avaient réellement tués. Étant donné que les joueurs doivent attendre les vagues et ne pas les tuer pour recevoir des récompenses et des réalisations, une telle stratégie est absolument efficace et brillante - c'est exactement le comportement des joueurs que j'ai stimulé.

Malheureusement, cela s'avère également être un cas pathologique pour les performances, en particulier lorsque les joueurs veulent jouer à des vitesses 16x ou 8x. Bien sûr, seuls les joueurs les plus hardcore essaieront d'obtenir le succès "Hundredth Wave" dans New Game + au niveau Endless 2, mais ce ne sont que ceux qui parlent le jeu le plus fort, donc je voulais qu'ils soient heureux.

C'est juste un jeu 2D avec un tas de sprites, qu'est-ce qui pourrait ne pas y arriver?

Et en effet. Faisons les choses correctement.

2. Résolution des collisions


Jetez un œil à cette capture d'écran:


Vous voyez ce bagel autour du ranger? C'est sa zone d'impact - notez qu'il y a aussi une zone morte dans laquelle il ne peut pas toucher des cibles. Chaque classe a sa propre zone d'attaque, et chaque défenseur a une zone de taille différente, selon le niveau de boost et les paramètres personnels. Et chaque défenseur peut en théorie viser n'importe quel ennemi dans le champ de sa portée. Il en va de même pour certains types d'ennemis. Il peut y avoir jusqu'à 36 défenseurs sur la carte (sans compter le personnage principal Azru), mais il n'y a pas de limite supérieure sur le nombre d'ennemis. Chaque défenseur et ennemi a une liste de cibles possibles, créée sur la base d'appels pour vérifier la zone à chaque étape de mise à jour (moins la coupure logique de ceux qui ne peuvent pas attaquer pour le moment, etc.).

Aujourd'hui, les processeurs vidéo sont très rapides - si vous ne les forcez pas trop, ils peuvent alors traiter presque n'importe quel nombre de polygones. Mais même les CPU les plus rapides ont très facilement des «goulots d'étranglement» dans les procédures simples, en particulier celles qui croissent de façon exponentielle. C’est pourquoi un jeu 2D peut se révéler plus lent qu’un jeu 3D beaucoup plus beau - non pas parce que le programmeur ne pouvait pas faire face (peut-être que c’est aussi, du moins dans mon cas), mais en principe parce que la logique peut parfois être plus chère, que de dessiner! La question n'est pas de savoir combien d'objets sont à l'écran, mais ce qu'ils font .

Explorons et accélérons la reconnaissance des collisions. À titre de comparaison, je dirai qu'avant l'optimisation, la reconnaissance des collisions prenait jusqu'à ~ 50% du temps processeur dans le cycle de bataille principal. Après optimisation, moins de 5%.

Tout tourne autour des arbres quadrants


La principale solution au problème de la reconnaissance de collision lente est de diviser l'espace - et dès le début, nous avons utilisé une implémentation de haute qualité de l'arbre quadrant . Essentiellement, il sépare efficacement l'espace de sorte que de nombreux contrôles de collision facultatifs peuvent être ignorés.

Dans chaque image, nous mettons à jour l'arborescence entière des quadrants (QuadTree) pour suivre la position de chaque objet, et lorsque l'ennemi ou le défenseur veut viser quelqu'un, il demande à QuadTree une liste des objets à proximité. Mais le profileur nous a dit que ces deux opérations sont beaucoup plus lentes qu'elles ne devraient l'être.

Qu'est-ce qui ne va pas ici?

Il s'est avéré - beaucoup.

Saisie de chaînes


Comme j'ai gardé les ennemis et les défenseurs dans un arbre quadrant, j'ai dû indiquer ce que je cherchais, et cela a été fait comme ceci:

var things:Array<XY> = _qtree.queryRange(zone.bounds, "e"); //"e" - "enemy"

Dans le jargon des programmeurs, cela s'appelle du code de frappe de chaîne et, entre autres raisons, c'est mauvais parce que les comparaisons de chaînes sont toujours plus lentes que les comparaisons entières.

J'ai rapidement récupéré des constantes entières et remplacé le code par ceci:

var things:Array<XY> = _qtree.queryRange(zone.bounds, QuadTree.ENEMY);

(Oui, cela valait probablement la peine d'utiliser Enum Abstract pour une sécurité maximale du type, mais j'étais pressé et je devais d'abord faire le travail.)

Ce changement à lui seul a apporté une énorme contribution, car cette fonction est appelée constamment et récursivement, chaque fois que quelqu'un a besoin d'une nouvelle liste d'objectifs.

Tableau vs vecteur


Jetez un œil à ceci:

var things:Array<XY>

Les tableaux Haxe sont très similaires aux tableaux ActionScript et JS en ce qu'ils sont des collections d'objets redimensionnables, mais dans Haxe, ils sont fortement typés.

Cependant, il existe une autre structure de données plus efficace avec les langages cibles statiques tels que cpp, à savoir haxe.ds.Vector . Les vecteurs Haxe sont essentiellement les mêmes que les tableaux, sauf que lorsqu'ils sont créés, ils obtiennent une taille fixe.

Comme mes arbres quadrants avaient déjà un volume fixe, j'ai remplacé les tableaux par des vecteurs pour obtenir une augmentation de vitesse notable.

Demandez uniquement ce dont vous avez besoin


Auparavant, ma fonction queryRange une liste d'objets, des instances XY . Ils contenaient les coordonnées x / y de l'objet de jeu référencé et son identifiant d'entier unique (index de recherche dans le tableau principal). L'objet de jeu exécutant la demande a reçu ces XY, extrait un identifiant entier pour obtenir sa cible, puis a oublié le reste.

Alors pourquoi devrais-je passer toutes ces références aux objets XY pour chaque nœud QuadTree de manière récursive , et même 960 fois par trame? Il me suffit de renvoyer une liste d'identifiants entiers.

CONSEIL PROFESSIONNEL: les nombres entiers sont beaucoup plus rapides à transmettre que presque tous les autres types de données!

Comparé à d'autres corrections, c'était assez simple, mais la croissance des performances était encore perceptible, car cette boucle interne était utilisée très activement.

Optimisation de la récursivité de la queue


Il existe une chose élégante appelée optimisation de l'appel de queue . C'est difficile à expliquer, je ferai donc mieux de vous montrer un exemple.

C'était:

nw.queryRange(Range, -1, result);
ne.queryRange(Range, -1, result);
sw.queryRange(Range, -1, result);
se.queryRange(Range, -1, result);
return result;


C'est devenu:

 return se.queryRange(Range, filter, sw.queryRange(Range, filter, ne.queryRange(Range, filter, nw.queryRange(Range, filter, result)))); 

Le code renvoie les mêmes résultats logiques, mais selon le profileur, la deuxième option est plus rapide, au moins lors de la traduction en cpp. Les deux exemples exécutent exactement la même logique - ils modifient la structure de données «résultat» et la transmettent à la fonction suivante avant de revenir. Lorsque nous le faisons récursivement, nous pouvons éviter que le compilateur génère des références temporaires, car il peut simplement retourner immédiatement le résultat de la fonction précédente, plutôt que de s'y tenir dans une étape supplémentaire. Ou quelque chose comme ça. Je ne comprends pas bien comment cela fonctionne, alors lisez l'article sur le lien ci-dessus.

(À en juger par ce que je sais, la version actuelle du compilateur Haxe n'a pas de fonction d'optimisation de récursivité de queue, c'est-à-dire que c'est probablement le travail du compilateur C ++ - alors ne soyez pas surpris si cette astuce ne fonctionne pas lors de la traduction de code Haxe pas en cpp.)

Pool d'objets


Si j'ai besoin de résultats précis, je dois à nouveau détruire et reconstruire QuadTree à chaque appel de mise à jour. La création de nouvelles instances QuadTree est une tâche assez courante, mais avec un grand nombre de nouveaux objets AABB et XY, les QuadTrees qui en dépendent ont entraîné une grave surcharge de mémoire. Comme ce sont des objets très simples, il serait logique d'allouer beaucoup de ces objets à l'avance et de les réutiliser constamment. C'est ce qu'on appelle un pool d'objets .

Je faisais quelque chose comme ça:

nw = new QuadTree( new AABB( cx - hs2x, cy - hs2y, hs2x, hs2y) );
ne = new QuadTree( new AABB( cx + hs2x, cy - hs2y, hs2x, hs2y) );
sw = new QuadTree( new AABB( cx - hs2x, cy + hs2y, hs2x, hs2y) );
se = new QuadTree( new AABB( cx + hs2x, cy + hs2y, hs2x, hs2y) );


Mais j'ai remplacé le code par ceci:

nw = new QuadTree( AABB.get( cx - hs2x, cy - hs2y, hs2x, hs2y) );
ne = new QuadTree( AABB.get( cx + hs2x, cy - hs2y, hs2x, hs2y) );
sw = new QuadTree( AABB.get( cx - hs2x, cy + hs2y, hs2x, hs2y) );
se = new QuadTree( AABB.get( cx + hs2x, cy + hs2y, hs2x, hs2y) );


Nous utilisons le framework open source HaxeFlixel , nous l'avons donc implémenté en utilisant la classe FlxPool HaxeFlixel. Dans le cas de telles optimisations hautement spécialisées, je remplace souvent certains éléments de base de Flixel (par exemple, la reconnaissance des collisions) par ma propre implémentation (comme je l'ai fait avec QuadTrees), mais FlxPool est meilleur que tout ce que j'ai écrit moi-même et il fait exactement ce dont il a besoin.

Spécialisation si nécessaire


Un objet XY est une classe simple qui a les propriétés x , y et int_id . Puisqu'il a été utilisé dans une boucle interne particulièrement active, j'ai pu économiser beaucoup de commandes et d'opérations d'allocation de mémoire en déplaçant toutes ces données vers une structure de données spéciale qui fournit les mêmes fonctionnalités que Vector<XY> . J'ai appelé cette nouvelle classe XYVector et le résultat peut être vu ici . Il s'agit d'une application très hautement spécialisée et non flexible en même temps, mais elle nous a apporté quelques améliorations de vitesse.

Fonctions intégrées


Maintenant, après avoir terminé la vaste phase de reconnaissance des collisions, nous devons effectuer de nombreuses vérifications pour savoir quels objets entrent en collision. Lorsque c'est possible, j'essaie de comparer des points et des chiffres, pas des chiffres et des chiffres, mais parfois je dois faire ces derniers. Dans tous les cas, tout cela nécessite ses propres contrôles spéciaux:

 private static function _collide_circleCircle(a:Zone, b:Zone):Bool { var dx:Float = a.centerX - b.centerX; var dy:Float = a.centerY - b.centerY; var d2:Float = (dx * dx) + (dy * dy); var r2:Float = (a.radius2) + (b.radius2); return d2 < r2; } 


Tout cela peut être amélioré avec un seul inline :

 private static inline function _collide_circleCircle(a:Zone, b:Zone):Bool { var dx:Float = a.centerX - b.centerX; var dy:Float = a.centerY - b.centerY; var d2:Float = (dx * dx) + (dy * dy); var r2:Float = (a.radius2) + (b.radius2); return d2 < r2; } 


Lorsque nous ajoutons inline à une fonction, nous demandons au compilateur de copier et coller ce code et de coller les variables lorsqu'il est utilisé, et de ne pas faire d'appel externe à une fonction distincte, ce qui entraîne des coûts inutiles. L'incorporation n'est pas toujours applicable (par exemple, elle gonfle la quantité de code), mais elle est idéale pour les situations où de petites fonctions sont appelées encore et encore.

Nous évoquons les conflits


La vraie leçon ici est que dans le monde réel, les optimisations ne sont pas toujours du même type. Ces correctifs sont un mélange de techniques avancées, de hacks bon marché, d'application de recommandations logiques et d'élimination des erreurs stupides. Tout cela en général nous donne une amélioration des performances.

Mais encore - mesurez sept fois, coupez-en un!

Deux heures d'optimisation pédante de la fonction, appelées une fois toutes les six images et prenant 0,001 ms, ne valent pas l'effort, malgré la laideur et la stupidité du code.

3. Triez tout


En fait, c'était l'une de mes dernières améliorations, mais elle s'est avérée si avantageuse qu'elle mérite son propre titre. De plus, c'était le plus simple et il a fait ses preuves à plusieurs reprises. Le profileur m'a montré une procédure que je ne pouvais pas améliorer du tout - la boucle draw () principale, qui prenait trop de temps. La raison en était la fonction qui triait tous les éléments de l'écran avant le rendu - à savoir, le tri de tous les sprites prenait beaucoup plus de temps que de les dessiner!

Si vous regardez les captures d'écran du jeu, vous verrez que tous les ennemis et défenseurs sont d'abord triés par y , puis par x , de sorte que les éléments se chevauchent d'arrière en avant, de gauche à droite, lorsque nous passons du coin supérieur gauche au coin inférieur droit de l'écran.

Une façon de déjouer le tri est de simplement passer le tri du rendu à travers le cadre. C'est une astuce utile pour certaines fonctions coûteuses, mais cela a immédiatement conduit à des bugs visuels très visibles, donc cela ne nous convenait pas.

Enfin, la décision est venue de l'un des mainteneurs de HaxeFlixel, Jens Fisher . Il a demandé: "Vous êtes-vous assuré d'utiliser un algorithme de tri rapide pour les tableaux presque triés?"

Non! Il s'est avéré que non. J'ai utilisé le tri de tableaux à partir de la bibliothèque standard Haxe (je pense que c'était un tri par fusion - un bon choix pour les cas généraux. Mais j'avais un cas très spécial . Lors du tri dans chaque image, la position de tri ne change qu'un très petit nombre de sprites, même s'il y en a beaucoup. Par conséquent J'ai remplacé l'ancien appel de tri par un tri par encarts et boum! - la vitesse a instantanément augmenté.

4. Autres problèmes techniques


La reconnaissance et le tri des collisions ont été de grandes victoires dans la logique de update() et draw() , mais de nombreux autres pièges étaient cachés dans les boucles internes activement utilisées.

Std.is () et cast


Dans différentes boucles internes "chaudes", j'avais un code similaire:

 if(Std.is(something,Type)) { var typed:Type = cast(something,Type); } 


Dans le langage Haxe, Std.is() nous indique si un objet appartient à un type spécifique (Type) ou à une classe (Class), et cast essaie de le convertir en un type spécifique pendant l'exécution du programme.

Il existe des versions sûres et non protégées des cast sûrs pour la cast , ce qui réduit les performances, mais pas les lancements non protégés.

Coffre-fort: cast(something, Type);

Non protégé: var typed:Type = cast something;

Lorsqu'une tentative de distribution non sécurisée échoue, nous obtenons une valeur nulle, tandis qu'une distribution sécurisée lève une exception. Mais si nous n'attrapons pas d'exception, quel est l'intérêt de faire un casting sûr? Sans prise, l'opération échoue toujours, mais elle fonctionne plus lentement.

De plus, il est inutile de faire précéder une distribution sécurisée avec la Std.is() . La seule raison d'utiliser un casting sûr est une exception garantie, mais si nous vérifions le type avant le casting, nous garantissons déjà que le casting n'échouera pas!

Je peux accélérer un peu les Std.is() avec un casting Std.is() après avoir vérifié Std.is() . Mais pourquoi devons-nous réécrire la même chose si je n'ai pas du tout besoin de vérifier le type de classe?

Supposons que j'ai un CreatureSprite , qui peut être une instance d'une sous-classe de DefenderSprite ou EnemySprite . Au lieu d'appeler Std.is(this,DefenderSprite) nous pouvons créer un champ entier dans CreatureSprite avec des valeurs comme CreatureType.DEFENDER ou CreatureType.ENEMY , qui sont vérifiées encore plus rapidement.

Je le répète, cela ne vaut la peine de le réparer que dans les endroits où un ralentissement significatif est clairement enregistré.

Au fait, vous pouvez en savoir plus sur la distribution sûre et non protégée dans le manuel Haxe .

Sérialisation / désérialisation de l'univers


C'était ennuyeux de trouver de tels endroits dans le code:

 function copy():SomeClass { return SomeClass.fromXML(this.toXML()); } 

Ouais. Pour copier un objet, nous le sérialisons en XML , puis analysons tout ce XML , après quoi nous rejetons instantanément le XML et retournons un nouvel objet. C'est probablement le moyen le plus lent de copier un objet, en plus, cela surcharge la mémoire. Au départ, j'ai écrit des appels XML pour enregistrer et charger à partir du disque, et je pense que j'étais trop paresseux pour écrire les bonnes procédures de copie.

Probablement, tout serait en ordre si cette fonction était rarement utilisée, mais ces appels ont surgi à des endroits inappropriés au milieu du gameplay. Alors je me suis assis et j'ai commencé à écrire et à tester la fonction de copie correcte.

Dites non à Null


La vérification de l'égalité pour null est utilisée assez souvent, mais lors de la conversion de Haxe en cpp, un objet qui permet une valeur indéfinie entraîne des coûts inutiles qui ne surviennent pas si le compilateur peut supposer que l'objet ne sera jamais nul. Cela est particulièrement vrai pour les types de base comme Int - Haxe implémente la validité d'une valeur indéfinie pour eux dans le système cible statique par leur «emballage», ce qui se produit non seulement pour les variables qui sont explicitement déclarées nulles ( var myVar:Null<Int> ), mais aussi pour des choses comme les options d'assistance ( ?myParam:Int ). De plus, les contrôles nuls eux-mêmes provoquent des déchets inutiles.

J'ai pu résoudre certains de ces problèmes simplement en regardant le code et en pensant à des alternatives - puis-je faire une vérification plus simple, qui sera toujours vraie lorsque l'objet est nul? Puis-je intercepter null beaucoup plus tôt dans la chaîne d'appels de fonction et transmettre un indicateur entier ou booléen simple aux appels enfants? Puis-je tout structurer pour que la valeur ne soit jamais garantie de devenir nulle? Et ainsi de suite. Nous ne pouvons pas éliminer complètement les vérifications nulles et les valeurs nullables, mais les retirer des fonctions m'a beaucoup aidé.

5. Temps de téléchargement


Sur PSVita, nous avons eu de sérieux problèmes particuliers avec le temps de chargement de certaines scènes. Lors du profilage, il s'est avéré que les raisons se résument principalement à la pixellisation du texte, au rendu logiciel inutile, au rendu des boutons coûteux et à d'autres choses.

Texte


HaxeFlixel est basé sur OpenFL , qui a TextField impressionnant et fiable. Mais j'ai utilisé des objets FlxText de manière imparfaite - les objets FlxText ont un champ de texte OpenFL qui est pixellisé. Cependant, il s'est avéré que je n'avais pas besoin de la plupart de ces fonctions de texte complexes, mais en raison de la façon stupide de configurer mon système d'interface utilisateur, les champs de texte devaient être rendus avant que tous les autres objets ne soient localisés. Cela a conduit à des sauts petits mais perceptibles, par exemple, lors du chargement d'une fenêtre contextuelle.

Ici, j'ai fait trois corrections - tout d'abord, j'ai remplacé autant de texte que possible par des polices raster. Flixel prend en charge différents formats de polices raster, y compris le BMFont d'AngelCode , ce qui facilite le travail avec Unicode, le style et le crénage, mais l'API de texte raster est légèrement différente de l'API de texte brut, j'ai donc dû écrire une petite classe wrapper pour simplifier la transition. (Je lui ai donné un nom approprié FlxUITextHack ).

Cela a légèrement amélioré le travail - les polices bitmap s'affichent très rapidement - mais la complexité a légèrement augmenté: j'ai dû préparer spécialement des jeux de caractères séparés et ajouter une logique de commutation entre eux en fonction des paramètres régionaux, au lieu de simplement configurer une zone de texte qui a fait tout le travail.

Le deuxième correctif consistait à créer un nouvel objet d'interface utilisateur qui était un simple espace réservé pour le texte mais qui avait les mêmes propriétés publiques que le texte. Je l'ai appelé «zone de texte» et j'ai créé une nouvelle classe pour cela dans ma bibliothèque d'interface utilisateur afin que mon système d'interface utilisateur puisse utiliser ces zones de texte de la même manière que les champs de texte réels, mais il ne restitue rien tant qu'il n'a pas calculé la taille et la position pour tout le reste. Ensuite, lorsque ma scène a été préparée, j'ai commencé la procédure de remplacement de ces zones de texte par de vrais champs de texte (ou champs de texte de polices bitmap).

La troisième correction concernait la perception. S'il y a des pauses entre l'entrée et la réaction même en une demi-seconde, le joueur perçoit cela comme un freinage. Par conséquent, j'ai essayé de trouver toutes les scènes dans lesquelles il y a un retard dans l'entrée jusqu'à la transition suivante, et j'ai ajouté soit un calque translucide avec le mot "Loading ..." soit juste un calque sans texte. Une telle correction simple a considérablement amélioré la perception de la réactivité du jeu, car quelque chose se produit immédiatement après que le joueur a touché la commande, même si cela prend un certain temps pour afficher le menu.

Rendu logiciel


La plupart des menus utilisent une combinaison de mise à l'échelle logicielle et de composition à 9 tranches. Cela est arrivé parce que dans la version PC, il y avait une interface utilisateur indépendante de la résolution qui pouvait fonctionner avec un rapport d'aspect de 4: 3 et 16: 9, mis à l'échelle en conséquence. Mais sur PSVita, nous connaissons déjà la résolution, c'est-à-dire que nous n'avons pas besoin de toutes ces ressources extra-haute résolution et algorithmes de mise à l'échelle en temps réel. Nous pouvons simplement pré-rendre les ressources à la résolution exacte et les placer sur l'écran.

Tout d'abord, je suis entré dans le balisage de l'interface utilisateur pour les conditions Vita qui ont changé le jeu en utilisant un ensemble parallèle de ressources. Ensuite, je devais créer ces ressources préparées pour une autorisation. Le débogueur HaxeFlixel s'est avéré très utile ici - j'y ai ajouté mon script pour qu'il vide simplement le cache raster sur le disque. Ensuite, j'ai créé une configuration de construction spéciale pour Windows qui simule l'autorisation de Vita, ouvert tour à tour tous les menus du jeu, basculé vers le débogueur et lancé la commande d'exportation pour les versions à l'échelle des ressources sous la forme de fichiers PNG prêts à l'emploi. Ensuite, je les ai simplement renommés et les ai utilisés comme ressources pour Vita.

Rendu des boutons


Mon système d'interface utilisateur avait un vrai problème avec les boutons - lorsqu'ils ont été créés, les boutons ont rendu le jeu de ressources par défaut, et un instant plus tard, ils ont redimensionné (et rendu à nouveau) le code de démarrage de l'interface utilisateur, et parfois même la troisième fois, avant que l'interface utilisateur entière ne soit chargée . J'ai résolu ce problème en ajoutant des options qui retardaient le rendu des boutons à la dernière étape.

Numérisation de texte en option


Le magazine se chargeait particulièrement lentement. Au début, je pensais que le problème venait des champs de texte, mais non. Le texte du magazine pouvait contenir des liens vers d'autres pages, ce qui était indiqué par des caractères spéciaux intégrés dans le texte brut lui-même. Ces caractères ont ensuite été découpés et utilisés pour calculer l'emplacement du lien.

Cela s'est avéré. que j'ai scanné chaque champ de texte pour trouver et remplacer ces caractères par des liens correctement formatés, sans même vérifier d'abord s'il y a un caractère spécial dans ce champ de texte! Pire, selon la conception, les liens n'étaient utilisés que sur la page de contenu, mais je les ai vérifiés dans chaque zone de texte de chaque page.

J'ai réussi à contourner toutes ces vérifications en utilisant la construction if du formulaire "Cette zone de texte utilise-t-elle des liens". La réponse à cette question était généralement non. Enfin, la page qui a mis le plus de temps à se charger s'est avérée être la page d'index. Comme il ne change jamais dans le menu du journal, pourquoi ne le cache-t-on pas?

6. Profilage de la mémoire


La vitesse n'est pas seulement le CPU. La mémoire peut également être un problème, en particulier sur les plates-formes faibles comme Vita. Même lorsque vous avez réussi à vous débarrasser de la dernière fuite de mémoire, vous pouvez toujours avoir des problèmes avec l'utilisation de la mémoire en dents de scie dans un environnement de récupération de place.

Quelle est l'utilisation de la mémoire en dents de scie? Le garbage collector fonctionne comme suit: les données et les objets que vous n'utilisez pas s'accumulent au fil du temps et sont périodiquement effacés. Mais vous n'avez pas de contrôle clair sur le moment où cela se produit, donc le graphique d'utilisation de la mémoire ressemble à une scie:



Sortez la poubelle


Étant donné que le nettoyage n'est pas instantané, la quantité totale de RAM que vous utilisez est généralement supérieure à ce dont vous avez vraiment besoin. Mais si vous dépassez la quantité totale de RAM système, deux choses peuvent se produire - sur un PC, vous utilisez probablement simplement un fichier d'échange , c'est-à-dire convertissez temporairement une partie de l'espace du disque dur en RAM virtuelle. Une alternative dans les environnements à mémoire limitée (comme les consoles) consiste à planter l'application, même s'il n'y avait pas assez d'une paire misérable d'octets. Et cela se produira même si vous n'utilisez pas ces octets et le ramasse-miettes y sera bientôt effectué!

La bonne chose à propos de Haxe est qu'il est complètement open source, c'est-à-dire que vous n'êtes pas enfermé dans une boîte noire que vous ne pouvez pas réparer, comme c'est le cas avec Unity. Et le backend hxcpp fournit une gestion étendue de la récupération de place directement à partir de l'API!

Nous les avons utilisés pour effacer instantanément la mémoire après un grand niveau afin de rester dans les limites données:

cpp.vm.Gc.run(false); // (true/false - / )

vous ne devriez pas l'utiliser involontairement si vous ne savez pas ce que vous faites, mais il est pratique que de tels outils existent quand ils sont nécessaires.

7. Solution de contournement par la conception


Toutes ces améliorations de performances étaient plus que suffisantes pour optimiser le jeu pour le PC, mais nous avons également essayé de publier une version pour PSVita, et nous avions des plans à long terme pour la Nintendo Switch, nous avons donc dû tout presser, du code à la baisse.

Mais il y a souvent une «vision tunnel» lorsque vous vous concentrez uniquement sur les hacks techniques et oubliez qu'un simple changement de conception peut améliorer considérablement la situation .

Accélérer les effets à grande vitesse


À 16x, de nombreux effets se produisent si rapidement que le joueur ne les voit même pas. Nous avons déjà utilisé une astuce: la foudre d'Azra est devenue plus facile avec la vitesse du jeu et le nombre de particules pour les attaques AOE est plus faible. Nous avons complété cette technique en désactivant les nombres de dommages à grande vitesse et d'autres astuces similaires.

Nous avons également réalisé qu'à un moment donné, la vitesse de 16x peut en fait être plus lente que la vitesse de 8x lorsqu'il y a trop d'objets sur l'écran, donc lorsque le nombre d'ennemis augmente jusqu'à une certaine limite, nous réduisons automatiquement la vitesse de jeu à 8x ou 4x. En pratique, le joueur ne verra probablement cela que dans Endless Battle 2. Cela permet des performances et un rendu fluides sans surcharger le CPU.

Nous avons également utilisé des restrictions spécifiquement pour la plate-forme. Sur Vita, nous ignorons l'effet de la foudre lorsque Azra déclenche ou accélère le personnage, et utilisons d'autres astuces similaires.

Masquer le corps


Et qu'en est-il de l'énorme tas d'ennemis dans le coin inférieur droit d'Endless Battle 2 - il y a littéralement des centaines, voire des milliers d' ennemis tirant l'un sur l'autre. Pourquoi ne sautons-nous pas simplement le rendu de ceux que nous ne pouvons même pas voir?

Il s'agit d'une astuce de conception astucieuse qui nécessite une programmation astucieuse, car nous avons besoin d'un algorithme intelligent qui définit les objets cachés.

La plupart de ces jeux sont dessinés en utilisant l'algorithme de l'artiste - les objets précédents dans la liste de dessins sont bloqués par tout ce qui vient après eux.

En inversant l'ordre de rendu de l'algorithme de l'artiste, vous pouvez générer une «carte de couverture» et découvrir ce qui doit être caché. J'ai créé une fausse «toile» avec 8 niveaux d '«obscurité» (juste un tableau d'octets en deux dimensions) avec une résolution beaucoup plus faible qu'un vrai champ de bataille. À partir de la fin de la liste de rendu, nous prenons le cadre de délimitation de chaque objet et le «dessinons» sur la toile, en augmentant «l'obscurité» du point de 1 pour chaque «pixel» couvert par le cadre de délimitation basse résolution. En même temps, nous lisons «l'obscurité» moyenne de la zone dans laquelle nous allons dessiner. En fait, nous prédisons le nombre de redessins que chaque objet subira avec un véritable appel de dessin.

Si le nombre prévu de retraits est suffisamment élevé, je marque l'ennemi comme «enterré», avec deux seuils - complètement enterré, c'est-à-dire complètement invisible ou partiellement enterré, c'est-à-dire qu'il sera dessiné, mais sans afficher de barre de santé.

(Au fait, voici la fonction de vérification des redessins.)

Pour que cela fonctionne correctement, vous devez configurer correctement la résolution de la carte de masquage. S'il est trop grand, nous devrons effectuer un groupe supplémentaire d'appels de dessin simplifiés, s'il est trop petit, nous cacherons les objets de manière trop agressive et obtiendrons des bogues visuels. Si vous sélectionnez correctement la carte, l'effet est à peine perceptible, mais l'augmentation de la vitesse est très perceptible - il n'y a aucun moyen de dessiner quelque chose plus rapidement que de ne pas le dessiner du tout !

Meilleure précharge que les freins


Au milieu des combats, j'ai remarqué des freinages fréquents qui, j'en étais sûr, étaient dus à une pause dans la collecte des ordures. Cependant, le profilage a montré que ce n'est pas le cas. Des tests supplémentaires ont révélé que cela se produit au début de la vague d'apparition d'ennemis, et plus tard, j'ai découvert que cela ne se produit que lorsque c'est une vague d'ennemis qui n'existait pas auparavant.. De toute évidence, un code de configuration ennemi a causé le problème, et bien sûr, lors du profilage, une fonction «chaude» a été trouvée dans les paramètres graphiques. J'ai commencé à travailler sur une configuration de téléchargement multi-thread complexe, mais j'ai réalisé que je pouvais simplement mettre toutes les procédures de chargement de graphiques ennemis dans la précharge de bataille. Séparément, il s'agissait de très petits téléchargements, même sur les plates-formes les plus lentes, ajoutant moins d'une seconde au temps total de chargement de la bataille, mais ils ont évité un freinage très notable pendant le gameplay.

Nous réservons du stock pour plus tard


Si vous travaillez dans un environnement avec une mémoire limitée, vous pouvez utiliser l'ancienne astuce de notre industrie - allouer une grande mémoire comme ça, puis l'oublier jusqu'à la fin du projet. A la fin du projet, après avoir gaspillé tout le budget mémoire disponible, vous pouvez être sauvé grâce à ce «nest egg».

Nous nous sommes retrouvés dans une telle situation - nous n'avions besoin que d'une douzaine d'octets pour enregistrer l'assemblage pour PSVita, mais bon sang - nous avons oublié cette astuce et nous sommes donc restés coincés! Les seules options restantes étaient des semaines de chirurgie désespérée et douloureuse du code!

Mais attendez un instant! L'une de mes optimisations (infructueuses) a été de charger autant de ressources que possible et perpétuellesles stocker en mémoire, car j'ai supposé à tort qu'un temps de chargement important était dû à la lecture des ressources lors de l'exécution du programme. Il s'est avéré que ce n'était pas le cas, donc presque tous ces appels supplémentaires pour le préchargement et le stockage éternel pouvaient être complètement supprimés, et j'avais encore de la mémoire libre!

Se débarrasser des choses que nous n'utilisons pas


En travaillant sur la construction de PSVita, nous avons été particulièrement clairs sur le fait qu'il y a un tas de choses dont nous n'avons tout simplement pas besoin. En raison de la faible résolution, le mode graphique source et le mode graphique HD étaient indiscernables, donc pour tous les sprites, nous avons utilisé les graphiques originaux. Nous avons également réussi à améliorer la fonction de remplacement de la palette à l'aide d'un pixel shader spécial (auparavant, nous utilisions la fonction de rendu de programme).

Un autre exemple était la carte de bataille elle-même - sur le PC et les consoles de salon, nous avons empilé un tas de cartes de tuiles les unes sur les autres pour créer une carte multicouche. Mais comme la carte ne change jamais, sur Vita, nous pouvions simplement tout faire en une seule image finie pour qu'elle soit appelée en un seul tirage.

En plus des ressources supplémentaires, le jeu a eu de nombreux appels supplémentaires, par exemple, les défenseurs et les ennemis envoyant un signal de régénération dans chaque image, même lorsqu'ils n'ont pas la capacité de se régénérer . Si l'interface utilisateur était ouverte pour une telle créature, elle a été redessinée dans chaque cadre .

Il existe une demi-douzaine d'autres exemples de petits algorithmes qui calculent quelque chose à l'intérieur d'une fonction «chaude», mais ne retournent jamais de résultats nulle part. Habituellement, ce sont les résultats de la création de la structure aux premiers stades de développement, nous les supprimons donc.

NaNopocalypse


Cette affaire était drôle. Le profileur a indiqué qu'il fallait beaucoup de temps pour calculer les angles. Voici le code Haxe C ++ généré dans le profileur:


C'est l'une de ces fonctions qui prennent des valeurs comme -90et se convertissent en 270. Parfois, vous obtenez des valeurs comme -724, qui en quelques cycles sont réduites à 4.

Pour une raison quelconque, une valeur a été transmise à cette fonction -2147483648.


Faisons les calculs. Si, dans chaque cycle, nous ajoutons 360 à -2147483648, il faudra alors environ 5 965 233 itérations jusqu'à ce qu'il devienne supérieur à 0 et termine le cycle. Soit dit en passant, ce cycle a été effectué à chaque mise à jour (pas dans chaque image - dans chaque mise à jour !) - chaque fois que le projectile (ou autre chose) changeait son angle.

Bien sûr, c'était de ma faute, car j'ai transmis la valeur NaN- une signification spéciale pour «Pas un nombre» (pas un nombre), qui signale généralement une erreur qui s'est déjà produite dans le code. Si vous l'amenez à un entier sans vérification préalable, alors de telles choses étranges se produisent.

Comme correctif temporaire, j'ai ajouté un chèqueMath.isNan(), qui réinitialise l'angle lorsqu'un tel événement (plutôt rare, mais inévitable) se produit. Dans le même temps, j'ai continué à rechercher la cause première de l'erreur, je l'ai trouvée et le retard a immédiatement disparu. Il s'avère que si vous n'effectuez pas 6 millions d'itérations inutiles, vous pouvez obtenir une grande augmentation de vitesse!

(Un correctif pour cette erreur a été inséré dans HaxeFlixel lui-même).

Ne te surpasse pas


OpenFL et HaxeFlixel sont basés sur la mise en cache des ressources. Cela signifie que lorsque nous chargeons une ressource, la prochaine fois qu'elle sera reçue, elle sera extraite du cache et non rechargée à partir du disque. Ce comportement peut être ignoré et parfois il a du sens.

Cependant, je suis entré dans des choses étranges et farfelues: j'ai téléchargé la ressource, j'ai explicitement dit au système de ne pas mettre en cache les résultats, parce que j'étais complètement sûr de ce que je faisais et ne voulais pas «gaspiller de la mémoire» sur le cache. Des années plus tard, ces appels «intelligents» m'ont fait charger la même ressource encore et encore, ralentissant le jeu et gaspillant une mémoire précieuse, que j'ai «sauvée» en abandonnant le cache.

8. De plus, cela peut ne pas valoir la peine de faire des niveaux comme Endless Battle 2


Oui, c'est génial d'avoir mis en place toutes ces petites astuces pour augmenter la vitesse. Honnêtement, nous n'avons pas remarqué la plupart d'entre eux jusqu'à ce que nous commencions à porter le jeu sur des systèmes moins puissants, quand à certains niveaux les problèmes sont devenus complètement intolérables. Je suis heureux qu'au final nous ayons réussi à augmenter la vitesse, mais je pense que la conception du niveau pathologique devrait également être évitée. Endless Battle 2 a mis trop de pression sur le système, en particulier par rapport à tous les autres niveaux du jeu .

Même après tous ces changements, la version PSVita ne peut toujours pas faire face à la conception originale d'Endless 2, et je ne voulais pas risquer la vitesse sur les modèles de base XB1 et PS4, j'ai donc changé l'équilibre pour les versions console d'Endless 2. J'ai réduit le nombre d'ennemis, mais augmenté leurs caractéristiques pour que le niveau ait à peu près la même difficulté. De plus, sur PSVita, nous avons limité le nombre d'ondes à cent pour éviter le risque de panne de mémoire, mais nous n'avons pas ajouté de restrictions sur la PS4 et la XB1. Grâce à cela, la réalisation de l'endurance est toujours aussi difficile sur toutes les consoles. Dans la version PC, la conception du niveau Endless Batlte 2 est restée inchangée.

Tout cela a été une leçon pour nous, dont nous tiendrons compte lors de la création de Defender's Quest II - nous serons très attentifs aux niveaux sans limite supérieure du nombre d'ennemis à l'écran! Bien sûr, les missions «sans fin» sont très attrayantes pour les fans de Tower Defense, donc je ne m'en débarrasserai pas complètement, mais qu'en est-il des niveaux avec des points de contrôle auxquels le joueur DOIT tout détruire à l'écran avant de passer aux vagues suivantes? Cela nous permettra non seulement de limiter le nombre d'ennemis à l'écran, mais aussi de réaliser des économies au milieu du niveau sans se soucier de sérialiser l'état de la soupe folle d'objets dans une bataille intense - il nous suffira de simplement enregistrer les coordonnées des défenseurs, augmenter les niveaux, etc.

9. Réflexions en conclusion


Les performances de jeu sont un sujet complexe, car les joueurs ne comprennent souvent pas ce que c'est, et nous ne devrions pas nous attendre à une telle compréhension de leur part. Mais j'espère que cet article vous a clarifié un peu comment tout se passe à l'intérieur, et vous en avez appris plus sur la façon dont la conception, les compromis techniques et les décisions simplement stupides ralentissent les jeux.

L'essentiel est que même dans un jeu avec un bon design développé par une équipe talentueuse, de tels petits fragments de code "rouillés" peuvent être trouvés absolument partout . Mais en pratique, seule une petite fraction d'entre eux affecte réellement les performances. La capacité de les détecter et de les éliminer est à la fois un art et une science.

Je suis heureux que nous profitions de tous ces avantages dans le développement de Defender's Quest II. Honnêtement, si nous n'avions pas fait de portage pour PSVita, je n'aurais probablement même pas essayé la moitié de ces optimisations. Et même si vous n'achetez pas le jeu pour PSVita, vous pouvez remercier cette petite console, qui a considérablement amélioré la vitesse de Defender's Quest.

Source: https://habr.com/ru/post/fr414135/


All Articles