En savoir plus sur le rendu dans les boucles de jeu

La méthode suivante est souvent employée pour implémenter une boucle de jeu :

while (playing) {
    advance state by one frame
    render the new frame
    sleep until it’s time to do the next frame
}

Cette méthode pose quelques problèmes, le principal étant l'idée selon laquelle le jeu peut définir ce qu'est une image ("frame"). La fréquence d'actualisation n'est pas la même selon les écrans, et elle peut varier au fil du temps. Si vous générez des images plus rapidement que ce que l'écran peut afficher, vous devez en ignorer une de temps en temps. Si vous les générez trop lentement, SurfaceFlinger échoue régulièrement à trouver un nouveau tampon et affiche à nouveau l'image précédente. Ces deux situations peuvent entraîner des glitches visibles.

Vous devez adapter la fréquence d'images de l'écran et faire progresser l'état du jeu en fonction du temps qui s'est écoulé depuis la dernière image. Pour ce faire, vous pouvez :

  • utiliser la bibliothèque Android Frame Pacing (recommandé) ;
  • bourrer la file d'attente de tampon (BufferQueue) et compter sur la contre-pression exercée par la "permutation des tampons" ;
  • utiliser le Chorégraphe (API 16 ou version ultérieure).

Bibliothèque Android Frame Pacing

Pour en savoir plus sur l'utilisation de cette bibliothèque, consultez Atteindre le "frame pacing" approprié.

Bourrage de la file d'attente

Cette opération est très simple à mettre en œuvre : il suffit de permuter les tampons aussi vite que possible. Dans les premières versions d'Android, cela pouvait entraîner une pénalité et une mise en veille de 100 ms par SurfaceView#lockCanvas(). Maintenant, le rythme est donné par la BufferQueue, qui est vidée aussi rapidement que le peut SurfaceFlinger.

Android Breakout présente un exemple de cette approche. Il utilise GLSurfaceView, qui s'exécute dans une boucle qui appelle le rappel onDrawFrame() de l'application, puis permute le tampon. Si la BufferQueue est pleine, l'appel eglSwapBuffers() attend qu'un tampon soit disponible. Les tampons deviennent disponibles lorsque SurfaceFlinger les libère, ce qui se produit après l'acquisition d'un nouveau tampon pour l'affichage. Comme cela se produit sur VSYNC, la fréquence de votre boucle de dessin correspond à la fréquence d'actualisation. Grosso modo.

Cette approche pose un certain nombre de problèmes. Tout d'abord, l'application est liée à l'activité de SurfaceFlinger, dont la durée varie selon la quantité de travail à effectuer et selon que le service est en concurrence ou non avec d'autres processus pour obtenir du temps CPU. Étant donné que l'état de votre jeu progresse en fonction du délai qui s'écoule entre les permutations de tampons, votre animation n'est pas mise à jour régulièrement. Toutefois, si vous atteignez 60 images par seconde avec des incohérences lissées au fil du temps, vous ne remarquerez probablement pas ces irrégularités.

Ensuite, les premières permutations de tampons se produisent très rapidement, car la BufferQueue n'est pas encore pleine. Le temps calculé entre les images est proche de zéro. Le jeu génère donc quelques images dans lesquelles rien ne se produit. Dans un jeu comme Breakout, qui met l'écran à jour à chaque actualisation, la file d'attente est toujours pleine, sauf lorsqu'une partie est lancée ou reprise après une mise en pause. L'effet n'est donc pas perceptible. Un jeu qui met l'animation en pause de temps en temps, puis revient en mode "aussi vite que possible", peut rencontrer des difficultés.

Chorégraphe

Le Chorégraphe vous permet de définir un rappel qui se déclenche lors de la VSYNC suivante. La valeur de VSYNC réelle est transmise en tant qu'argument. Ainsi, même si votre application ne s'active pas tout de suite, vous avez une idée précise du moment où la période d'actualisation de l'écran a débuté. L'utilisation de cette valeur, plutôt que de l'heure actuelle, permet d'obtenir une source temporelle cohérente pour la logique de mise à jour de l'état du jeu.

Malheureusement, le fait que vous receviez un rappel après chaque VSYNC ne garantit pas que votre rappel sera exécuté en temps voulu ni que vous serez en mesure d'agir suffisamment rapidement. Votre application doit détecter les situations dans lesquelles elle prend du retard et ignorer manuellement des images.

L'activité "Record GL app" de Grafika en est un exemple. Sur certains appareils (Nexus 4 et Nexus 5, par exemple), l'activité se met à ignorer des images si vous vous contentez de regarder sans rien faire. Le rendu GL est simple, mais parfois les éléments de la vue sont redessinés, et la transmission des mesures/de la mise en page peut prendre beaucoup de temps si l'appareil est en mode de consommation réduite. D'après Systrace, sous Android 4.4, cela prend 28 ms au lieu de 6 après le ralentissement des horloges. Si vous faites glisser votre doigt sur l'écran, ce geste est interprété comme une interaction avec l'activité, par conséquent la vitesse de l'horloge reste élevée et aucune image n'est ignorée.

La solution la plus simple consistait à ignorer une image dans le rappel du Chorégraphe si l'heure actuelle était postérieure de plus de N millisecondes à l'heure de VSYNC. Idéalement, la valeur de N est déterminée sur la base des intervalles de VSYNC observés précédemment. Par exemple, si l'intervalle d'actualisation est de 16,7 ms (60 images par seconde), vous pouvez ignorer une image en cas de retard de plus de 15 ms.

En observant l'exécution de l'activité "Record GL app", vous constaterez que le compteur d'images ignorées augmente, et vous verrez même un flash rouge sur la bordure lorsque des images sont ignorées. Mais l'animation ne sera pas saccadée, sauf si vos yeux vous jouent des tours. À 60 images par seconde, l'application peut ignorer une image de temps en temps sans que personne ne le remarque, tant que l'animation se maintient à une vitesse constante. Tout dépend de ce que vous dessinez, des caractéristiques de l'écran et de la capacité de l'utilisateur à détecter les à-coups.

Gestion des threads

De manière générale, si vous procédez au rendu sur SurfaceView, GLSurfaceView ou TextureView, vous devez le faire dans un thread dédié. Ne vous lancez jamais dans des opérations lourdes ou interminables sur le thread UI. Créez plutôt deux threads pour le jeu : un thread de jeu et un thread de rendu. Pour en savoir plus, consultez Améliorer les performances de votre jeu.

Breakout et l'activité "Record GL app" utilisent des threads de moteur de rendu dédiés, et ils mettent à jour l'état de l'animation sur ce thread. Cette approche est raisonnable tant que l'état du jeu peut être mis à jour rapidement.

D'autres jeux séparent complètement la logique de jeu et le rendu. Dans le cas d'un jeu simple qui ne fait que déplacer un bloc toutes les 100 ms, vous pouvez avoir un thread dédié qui ne fait que ça :

run() {
    Thread.sleep(100);
    synchronized (mLock) {
        moveBlock();
    }
}

(Vous pouvez baser le délai de veille sur une horloge fixe pour éviter les dérives ; sleep() n'est pas parfaitement cohérent, et moveBlock() prend une durée non nulle, mais vous voyez l'idée.)

Lorsque le code de dessin est activé, il s'empare du verrou, obtient la position actuelle du bloc, libère le verrou et dessine. Au lieu d'effectuer des déplacements fractionnés basés sur des temps delta inter-images, vous avez juste un thread qui déplace les éléments et un autre qui les dessine là où ils se trouvent au moment où le dessin commence.

Pour une scène d'une complexité quelconque, vous pouvez créer une liste d'événements à venir triés par heure d'activation, et qui restent en veille jusqu'à l'échéance de l'événement suivant, mais l'idée est la même.