12

FrameRate : le nerf de la guerre

avr

Tout développeur de jeu (d’action) qui se respecte doit forcément garder une vue sur la vitesse de déroulement de son jeu et donc s’assurer d’un framerate suffisant. C’est lui (le framerate) qui garanti un jeu agréable à jouer et pas trop fatigant pour les yeux du joueur.
Sur une plateforme comme Android, nombreux sont les petits problèmes pénibles qui vont compliquer la tâche du développeur : des devices différents, des résolutions différentes, un système multi-tâche et des fréquences d’écran différentes.
Bref. Comme je développer (lorsque j’ai le temps) mon moteur graphique 2D pour Android, c’est un peu l’occasion d’expliquer les choix que j’ai fais avec leur avantages et leur inconvénients.

Le framerate

Un peu d’histoire

Le framerate représente le nombre d’image (différentes) par seconde que votre application va réussir à tracer.
Il n’y a pas si longtemps, on avait pensé (qui? je ne sais pas) qu’un taux de rafraîchissement de 24 images par seconde était suffisant pour afficher une animation de façon parfaitement fluide. C’était probablement un coup du marketing, qui a du transformer une contrainte technique en argument commercial de génie !

Le fait est, que si vous regardez un scrolling horizontal régulier au cinéma (qui est en 24 images par seconde) ou sur votre télévision, vous allez remarquer comme un dédoublement de l’image. Ce phénomène est due au fait que votre oeil (ou votre cerveau je ne sais pas) est capable de voir bien plus que 24 images par secondes.
Je ne m’aventurerai pas à proposer un nombre, mais disons qu’à partir de 50 images secondes (c’est mon cas en tout cas), on n’a plus ce phénomène. Pour autant, si on ne voit plus ce dédoublement, le cerveau doit quand même lui se rendre compte de quelques chose, puisqu’on ressent une certaine fatigue à regarder longtemps un écran à 50 Hz.
Il faut alors monter à 60 ou même 72 Hz pour que l’observation de l’écran ne soit plus fatiguant pour nos yeux !

Du coup, disons simplement, que plus votre framerate est élevé, mieux c’est pour l’utilisateur de votre application.

Le framerate sous android

Lorsque vous utilisez une SurfaceView sous Android, ce dernier gère pour vous le double buffering. La SurfaceView est alors dotée de deux zones de mémoire utilisée pour l’affichage.

  • La première que nous appellerons “Buffer physique” contient les données qui sont réellement affichées sur le smartphone.
  • La seconde que nous appellerons “Buffer virtuel” contient les données qui seront prochaiement affichées sur le smartphone.

Concrètement, lorsque vous utilisez la fonction drawBitmap (ou tout autre fonction ayant pour effet de modifier des pixels) dans une SurfaceView, vous allez tracer dans le Buffer virtuel. Cela veut dire que ce que vous tracez n’est pas immédiatement visible à l’écran. Une fois que vous avez fini de tracer tout ce que vous avez à tracer, Android se charge d’échanger les deux buffers.
Le virtuel devient le physique et le physique devient le virtuel. Les pixels précédemment tracés deviennent visibles.

Cet échange (screen swap) réalisé par Android est étroitement lié au hardware que vous utilisez.
Certains smartphones vont être capables d’afficher 60 images différentes par seconde. C’est le cas de la Galaxy Tab.
D’autres seront capables d’en afficher un peu moins (Le GalaxyNote, lui ne peut pas afficher plus de 58 images différentes par seconde).
Enfin certains smartphones seront limités à 50 images par seconde.

Au mieux, Android pourra effectuer autant de “screen swap” que le permet le hardware.

le framerate et les développeurs

Evidemment, vous l’avez compris, il n’est pas possible d’avoir un nombre d’images supérieure aux capacités physique du smartphone.
Pourtant, il est fort utile de connaitre le framerate théorique. c’est à dire le framerate que votre application pourrait atteindre s’il n’y avait aucune limite en provenance du hardware.
C’est très utile car cela vous permet de vous laisser quelques marges de manœuvres. Si votre framerate théorique est proche de 60, alors il y a de grandes chances qu’à certain moment votre application saccade. Si par contre vous êtes plus prêt de 120 alors vous pouvez être tranquille, même si votre application tourne sur une machine un peu moins puissante, ça passera encore.

Comment calculer ce framerate ?

Votre application va réaliser tout un ensemble d’opérations de façon répétitive.
Par exemple dans la cas d’un jeu, vous allez par exemple:

  • Détecter les actions de l’utilisateur (la position de ses doigts sur l’écran)
  • Déplacer des objets en fonction des interactions du joueur
  • Mettre à jour certaines données de votre jeu (moteur physique par exemple)
  • Tracer l’image suivante

Pour connaitre le framerate, il faut donc diviser 1 seconde par le temps pris par l’ensemble des opérations qui permettent de générer une nouvelle image de votre jeu.
Vous obtenez alors un framerate instantané. Si votre cycle de jeu a pris moins de 16.6 ms, vous atteindrez donc le tant espéré 60 fps.

Comme il est fort probable que d’une scène à l’autre le temps de traitement des opérations varie, le mieux est encore de calculer le framerate à partir du temps maximum pris pour effectuer les opérations du jeu.
Ainsi vous avez un framerate plus petit, mais nettement plus sûr !

Optimiser le framerate

Alors évidement, c’est bien beau tout ça, mais on a en à pas grand chose à faire !!!
Ce qu’on veut c’est optimiser le framerate.

Pour y parvenir, il faut déjà bien comprendre le mécanisme sous-jasent au processus de “screen swap”.

le screen swap sous android

Notre SurfaceView appelle régulièrement une fonction qui s’appelle OnDraw et qui est la pierre angulaire de la SurfaceView.
Normalement, lorsqu’on commence le développement sous Android, on commence par mettre tout le code de son jeu dans cette méthode (ou des sous méthodes évidement). C’est à dire:

  • les opérations de mise à jour du jeu
  • les opérations de tracé

Si on fait ça, c’est parce que le OnDraw réalise à la fin de son traitement le “screen swap” à notre place. Le “swap screen” évite un effet de clignotement des images très désagréable.
Le problème c’est que pour réaliser le “screen swap”, La surfaceView doit attendre le bon moment. C’est à dire le moment ou le hardware lui indique qu’il peut échanger le buffer virtuel et le buffer physique. On peut appeler ce moment la “Vsync” (vertical synchro). J’appelle ça ainsi car il s’agit du moment ou le chip vidéo indique au système qu’il vient de finir d’envoyer vers l’écran les pixels à afficher et qu’il s’apprête à recommencer.

Si l’ensemble de votre code contenu dans la methode onDraw a pu s’effectuer en moins de 16.6ms, alors votre framerate atteint 60fps.
Sinon vous serez en dessous car le OnDraw aura raté quelques message Vsync en provenance du chip vidéo. Il sera alors obligé d’attendre le suivant !

Le OnDraw est très pratique car il libère le programmeur d’une partie du travail.
Par contre il pose un sérieux problème ! Le temps pendant lequel le OnDraw attend la Vsync est “perdu”.
En fait il n’est pas perdu, Android va en profiter pour allouer un peu de temps aux autres processus qui tournent (rappelons qu’Android est un système multitâche).

A la recherche du temps perdu !

Je l’ai mentionné un peu plus haut: d’une image à l’autre votre application peut nécessiter plus ou moins de temps pour mettre à jours ses données.
Supposons que la première image se calcule (et s’affiche) en 15ms, la deuxième en 17ms et les suivantes en 15ms.

Que va t-il se passer ?
Notre première image va pouvoir s’afficher en 60 fps.
La deuxième ( > 16.6 ms ) va s’afficher en 30 fps
Les suivantes en 60 fps.

Du coup l’utilisateur aura la sensation de voir votre jeu saccader. Pas glop !
C’est d’autant plus dommage, que si on avait pu commencer à calculer l’image 2 dès la fin du calcul de l’image 1 (sans attendre donc un nouveau passage dans le OnDraw) on aurait solutionné le problème.

Et bien ça tombe, bien, car il est possible de faire ça ! Et en plus ça offre de nombreux avantages.
L’idée est donc de lancer deux threads (Un thread est un processus, c’est à dire une sorte de sous programme de votre programme) !

Le premier thread est en charge de mettre à jour les données de votre application.
Le second aura pour but de tracer à l’écran. Il appellera donc OnDraw à un moment où un autre (en fait il ne fera que ça. Sauf que dans cette version du OnDraw il n’y a plus que le tracé).

Du coup, lorsque le second Thread attends la Vsync, le premier, lui peut déjà effectuer quelques opérations concernant l’image suivante.
Les avantages sont assez nombreux !

  • L’utilisation du CPU est maximisé (ou au moins amélioré). Ce qui était le but recherché
  • Si votre smartphone dispose d’un dual core, alors les deux processus vont s’exécuter en parallèle. Votre framerate va grimper en flèche.
  • Enfin ce procédé permet dans une certaine mesure de donner un avantage tout particulier au code assembleur (ou plus généralement au code natif)… Je vais y revenir.
Cas particulier des processeur multi coeur

L’un des principaux avantage de la programmation multi threadée est de répartir la charge de travail entre les coeurs du processeur (pour peu biensur que ce dernier en ai plusieurs).
A priori on imagine qu’il va falloir se lancer dans un truc super compliqué genre calculer la moitié de la scène avec chaque coeur.
En fait non, les deux threads (calcul et affichage) sont deja un premier pas important dans l’optimisation du framerate.

Prenons comme exemple le cas concret d’une scène de mon jeu sur mon Galaxy Note:

  • Le temps de tracé d’une scène est de 8.7 ms
  • Le temps de copie de la scène sur l’écran est de 3.9 ms

Sur un processeur mono coeur le framerate serait calculé ainsi:

fps = 1000 / (8.7 + 3.9) = 79 im/s

Mais l’Exynos 4210 est un dual core. Aussi le framerate est calculé ainsi

fps = 1000 / max(8.7, 3.9) = 1000 / 8.7 = 112 im/s

Juste en utilisant de la façon la plus simple possible les threads, on a déjà grandement optimisé le framerate !!!!
Le rendement optimal serait atteint si nos deux coeurs étaient exploité de façon identique.

L’assembleur c’est encore plus mieux que bien

Pour rappel, coder en assembleur impose quelques contraintes (voir Performance du tracé de sprites sous Android).
Il n’est, en effet, pas possible de tracer directement dans le buffer virtuel (ni le physique à plus forte raison) depuis une fonction assembleur appelée grâce au NDK!
En assembleur vous êtes obligé de tracer dans une bitmap (fournies par Java) puis de recopier cette bitmap sur le buffer virtuel à l’aide d’une méthode java.

Cela est assez contraignant et à un coup important en terme de cycles CPU.

Lorsqu’on utilise 2 threads (un pour le calcul et un pour le tracé), le thread du tracé ne fait que recopier la bitmap sur l’écran. L’autre thread quant à lui se charge de tracer en assembleur dans une bitmap.
Evidement, il est clair, qu’il n’est pas possible de tracer dans la même bitmap que celle qui est recopiée à l’écran par le deuxième thread.
Donc on utilise 2 bitmaps. On recrée en quelques sorte une partie du mécanisme géré par la SurfaceView, puisque qu’un des thread trace dans une bitmap (bitmap virtuel) pendant que l’autre recopie à l’écran la bitmap (bitmap physique) prête pour l’affichage.

Mais, ce qui est vraiment bien, c’est que cette contrainte fini par ce transformer en réel avantage.
Tout d’abord la segmentation Calcul / Affichage est naturelle lorsqu’on utilise le NDK puisqu’on ne peut pas faire autrement.
Mais surtout, pourquoi ce limiter à 2 buffers ?

La SurfaceView, elle, a deux buffers. C’est comme ça, on y peut rien.
Par contre, en assembleur, vous pouvez travailler avec 3, 4 ou même 5 buffers d’avance. Il vous suffit de créer autant de bitmap que vous voulez de buffers.

Cette possibilité offerte, va vous permettre d’absorber de grosses variations de temps de calcul. Plus votre programme à de buffers, moins il sera sensible aux variations de charge.
Ces grosses variations peuvent être le fruit de processus dont vous n’êtes pas responsable (d’autres applications de votre smartphone qui vont se déclencher de façon intempestives par exemple : synchro, push, …).

Combien de buffers ?

Combien faut-il prévoir de buffer d’avance ?
Alors on pourrait se dire que plus il y a de buffer d’avance, mieux c’est, mais ce n’est pas vraiment le cas!
Il faut bien comprendre que les buffers sont des “écrans” qui sont déjà calculés et qui ont donc vocation à être affichés sans ne plus pouvoir être modifiés !

Supposons que vous ayez 60 buffers d’avance et que votre jeu fonctionne à 60 fps. vous avez donc 1 seconde d’avance devant vous ! Super.
Mais que se passe t-il lorsque l’utilisateur réalise une action (il tire par exemple) ?
Et bien l’image représentant l’action de votre utilisateur, ne sera affichée que 60 frames plus tard (le temps que les 60 écrans d’avance précalculés soient utilisés).
Attendre une seconde entre l’action “je tire” et le tir effectif n’est pas concevable.

Dans la pratique 3 buffers (donc 1 affiché + 2 précalculés) sont un bon compromis. A la limite on peut aller jusqu’à 4 !
Cela donne un temps de réaction de 1/15eme de seconde (pour 4 buffers) entre l’action et la réaction. ça reste acceptable. A cette vitesse l’utilisateur ne s’apercevra de rien.

Conclusion

Le framerate est extrêmement important dans un jeu. C’est lui garanti la fluidité de votre jeu.
C’est lui aussi qui va soulager (ou non) les pauvres yeux du joueur.
Tout est donc bon pour optimiser ce dernier et essayer de le stabiliser (limiter les passages de 60 à 30 fps).

Pour autant l’objectif doit toujours être d’avoir une framerate constant à 60fps.
Tout développeur de jeux (d’action) garde toujours cet objectif en tête.

 | Tags:

One Response to “FrameRate : le nerf de la guerre”

  1. [...] Screen : Comme expliqué ici et là, on ne peut pas tracer directement sur l’écran en code natif. On trace donc dans une bitmap [...]

Répondre

Human control : 7 + 3 =