Que recherchez-vous ?
Engine & platform

Correction de Time.deltaTime dans Unity 2020.2 pour un gameplay plus fluide : Que faut-il faire ?

TAUTVYDAS ŽILYS / UNITY TECHNOLOGIESContributor
Oct 1, 2020|18 Min
Correction de Time.deltaTime dans Unity 2020.2 pour un gameplay plus fluide : Que faut-il faire ?
Cette page a été traduite automatiquement pour faciliter votre expérience. Nous ne pouvons pas garantir l'exactitude ou la fiabilité du contenu traduit. Si vous avez des doutes quant à la qualité de cette traduction, reportez-vous à la version anglaise de la page web.

Unity 2020.2 beta introduit un correctif à un problème qui afflige de nombreuses plateformes de développement : des valeurs Time.deltaTime incohérentes, qui entraînent des mouvements saccadés et bégayants. Lisez ce billet de blog pour comprendre ce qui se passait et comment la prochaine version d'Unity vous aide à créer un gameplay légèrement plus fluide.

Depuis l'aube des jeux vidéo, l'obtention d'un mouvement indépendant de l'image dans les jeux vidéo impliquait la prise en compte du temps delta de l'image :

void Update()
{
    transform.position += m_Velocity * Time.deltaTime;
}

Cela permet d'obtenir l'effet souhaité, à savoir un objet se déplaçant à une vitesse moyenne constante, quelle que soit la fréquence d'images du jeu. En théorie, il devrait également déplacer l'objet à un rythme régulier si votre taux de rafraîchissement est très élevé. Dans la pratique, la situation est bien différente. Si vous avez regardé les valeurs Time.deltaTime réellement rapportées, vous avez peut-être vu ceci :

6.854 ms
7.423 ms
6.691 ms
6.707 ms
7.045 ms
7.346 ms
6.513 ms

Ce problème affecte de nombreux moteurs de jeu, y compris Unity, et nous remercions nos utilisateurs de nous l 'avoir signalé. Heureusement, la version bêta d'Unity 2020.2 commence à y remédier.

Pourquoi cela se produit-il ? Pourquoi, lorsque la fréquence d'images est verrouillée à 144 fps constants, Time.deltaTime n'est-il pas égal à 1⁄144 secondes (~6,94 ms) à chaque fois ? Dans cet article de blog, je vous emmène sur le chemin de l'investigation et de la résolution de ce phénomène.

Qu'est-ce que le temps delta et pourquoi est-il important ?

En termes simples, le temps delta est le temps qu'il a fallu à votre dernière image pour s'achever. Cela semble simple, mais ce n'est pas aussi intuitif que vous le pensez. Dans la plupart des livres de développement de jeux, vous trouverez cette définition canonique d'une boucle de jeu :

while (true)
{
  ProcessInput();
  Update();
  Render();
}

Avec une telle boucle de jeu, il est facile de calculer le temps delta :

var time = GetTime();
while (true)
{
  var lastTime = time;
  time = GetTime();
  var deltaTime = time - lastTime;
  ProcessInput();
  Update(deltaTime);
  Render(deltaTime);
}

Bien que ce modèle soit simple et facile à comprendre, il est tout à fait inadapté aux moteurs de jeu modernes. Pour atteindre des performances élevées, les moteurs utilisent aujourd'hui une technique appelée "pipelining", qui permet à un moteur de travailler sur plus d'une image à la fois.

Comparez cela :

Cadre

A cela :

Cadre

Dans les deux cas, les différentes parties de la boucle de jeu prennent le même temps, mais dans le second cas, elles sont exécutées en parallèle, ce qui permet de produire plus de deux fois plus d'images dans le même laps de temps. La mise en pipeline du moteur modifie la durée de la trame, qui n'est plus égale à la somme de toutes les étapes du pipeline, mais à la plus longue d'entre elles.

Cependant, il s'agit là d'une simplification de ce qui se passe réellement à chaque image dans le moteur :

  • Chaque étape du pipeline prend un temps différent pour chaque image. Il se peut que cette image contienne plus d'objets à l'écran que la précédente, ce qui rendrait le rendu plus long. Ou bien le joueur a roulé son visage sur le clavier, ce qui a allongé le temps de traitement des données.
  • Étant donné que les différentes étapes du pipeline prennent plus ou moins de temps, nous devons arrêter artificiellement les étapes les plus rapides afin qu'elles ne prennent pas trop d'avance. Le plus souvent, on attend qu'une image précédente soit basculée dans la mémoire tampon avant (également appelée mémoire tampon de l'écran). Si la fonction VSync est activée, elle se synchronise en outre sur le début de la période VBLANK de l'écran. J'y reviendrai plus tard.

Sachant cela, examinons la chronologie d'une image typique dans Unity 2020.1. Étant donné que le choix de la plate-forme et divers paramètres ont une incidence importante, cet article se base sur un lecteur Windows Standalone dont le rendu multithread est activé, les tâches graphiques désactivées, vsync activé et QualitySettings.maxQueuedFrames réglé sur 2, fonctionnant sur un moniteur de 144 Hz sans perte d'images. Cliquez sur l'image pour la voir en taille réelle :

Cadre

Le pipeline d'images d'Unity n'a pas été implémenté à partir de zéro. Au contraire, il a évolué au cours de la dernière décennie pour devenir ce qu'il est aujourd'hui. Si vous vous reportez aux versions antérieures d'Unity, vous constaterez qu'il change toutes les quelques versions.

Vous remarquerez peut-être tout de suite certaines choses à son sujet :

  • Une fois que tout le travail est soumis au GPU, Unity n'attend pas que l'image soit affichée à l'écran : il attend l'image précédente. Ce paramètre est contrôlé par l'API QualitySettings.maxQueuedFrames. Ce paramètre décrit la distance entre le cadre affiché et le cadre en cours de rendu. La valeur minimale possible est 1, puisque le mieux que vous puissiez faire est de rendre framen+1 lorsque framen est affiché à l'écran. Puisqu'il est fixé à 2 dans ce cas (ce qui est la valeur par défaut), Unity s'assure que la frameen est affichée à l'écran avant de commencer le rendu de la framen+2 (par exemple, avant que Unity ne commence le rendu de la frame5, il attend que la frame3 apparaisse à l'écran).
  • Le rendu de l'image 5 prend plus de temps sur le GPU qu'un simple intervalle de rafraîchissement de l'écran (7,22 ms contre 6,94 ms) ; cependant, aucune des images n'est abandonnée. Cela se produit parce que QualitySettings.maxQueuedFrames avec la valeur 2 retarde l'apparition de l'image réelle à l'écran, ce qui produit une mémoire tampon dans le temps qui protège contre l'abandon d'images, tant que le "pic" ne devient pas la norme. S'il était fixé à 1, Unity aurait sûrement fait tomber le cadre, car il ne chevaucherait plus l'œuvre.

Même si le rafraîchissement de l'écran a lieu toutes les 6,94 ms, l'échantillonnage temporel d'Unity présente une image différente :

Mathématiques

La moyenne du delta dans ce cas ((7,27 + 6,64 + 7,03)/3 = 6,98 ms) est très proche du taux de rafraîchissement réel du moniteur (6,94 ms), et si vous deviez mesurer ce taux pendant une période plus longue, il finirait par s'établir à 6,94 ms exactement. Malheureusement, si vous utilisez ce delta temporel tel quel pour calculer le mouvement des objets visibles, vous introduirez une gigue très subtile. Pour illustrer cela, j'ai créé un simple projet Unity. Il contient trois carrés verts qui se déplacent dans l'espace mondial :

La caméra est fixée au cube supérieur, de sorte qu'elle apparaît parfaitement immobile à l'écran. Si Time.deltaTime est exact, les cubes du milieu et du bas semblent également immobiles. Les cubes se déplacent de deux fois la largeur de l'écran par seconde : plus la vitesse est élevée, plus la gigue est visible. Pour illustrer le mouvement, j'ai placé des cubes violets et roses immobiles dans des positions fixes à l'arrière-plan afin que vous puissiez déterminer la vitesse à laquelle les cubes se déplacent réellement.

Dans Unity 2020.1, les cubes du milieu et du bas ne correspondent pas tout à fait au mouvement du cube du haut - ils tremblent légèrement. Vous trouverez ci-dessous une vidéo capturée à l'aide d'une caméra à ralenti (ralentie 20 fois) :

Identifier la source de la variation du temps delta

D'où viennent donc ces incohérences de temps delta ? L'écran affiche chaque image pendant une durée fixe, en changeant d'image toutes les 6,94 ms. Il s'agit du véritable delta temporel, car c'est le temps qu'il faut pour qu'une image apparaisse à l'écran et c'est le temps pendant lequel le joueur de votre jeu observera chaque image.

Chaque intervalle de 6,94 ms se compose de deux parties : le traitement et le sommeil. L'exemple de la Timeline montre que le temps delta est calculé sur le thread principal, c'est donc sur lui que nous nous concentrerons. La partie traitement du thread principal consiste à pomper les messages du système d'exploitation, à traiter les entrées, à appeler Update et à émettre des commandes de rendu. "Attendre le fil de rendu" est la partie dormante. La somme de ces deux intervalles est égale à la durée réelle du cadre :

Mathématiques

Ces deux temps fluctuent pour diverses raisons à chaque image, mais leur somme reste constante. Si le temps de traitement augmente, le temps d'attente diminue et vice versa, de sorte qu'ils sont toujours exactement égaux à 6,94 ms. En fait, la somme de tous les éléments qui conduisent à l'attente est toujours égale à 6,94 ms :

Mathématiques

Cependant, Unity interroge le temps au début de la mise à jour. Pour cette raison, toute variation dans le temps nécessaire à l'émission des commandes de rendu, au pompage des messages du système d'exploitation ou au traitement des événements d'entrée faussera le résultat.

Une boucle simplifiée du fil principal d'Unity peut être définie comme suit :

while (!ShouldQuit())
{
  PumpOSMessages();
  UpdateInput();
  SampleTime(); // We sample time here!
  Update();
  WaitForRenderThread();
  IssueRenderingCommands();
}

La solution à ce problème semble être simple : il suffit de déplacer l'échantillonnage temporel après l'attente, de sorte que la boucle du jeu devienne celle-ci :

while (!ShouldQuit())
{
  PumpOSMessages();
  UpdateInput();
  Update();
  WaitForRenderThread();
  SampleTime();
  IssueRenderingCommands();
}

Cependant, ce changement ne fonctionne pas correctement : le rendu a des lectures de temps différentes de celles de Update(), ce qui a des effets négatifs sur toutes sortes de choses. Une option consiste à enregistrer le temps échantillonné à ce stade et à ne mettre à jour le temps du moteur qu'au début de la trame suivante. Cependant, cela signifierait que le moteur utiliserait le temps écoulé avant le rendu de la dernière image.

Puisque déplacer SampleTime() après Update() n'est pas efficace, peut-être que déplacer l'attente au début de la trame sera plus efficace :

while (!ShouldQuit())
{
  PumpOSMessages();
  UpdateInput();
  WaitForRenderThread();
  SampleTime();
  Update();
  IssueRenderingCommands();
}

Malheureusement, cela pose un autre problème : le thread de rendu doit maintenant terminer le rendu presque aussitôt que demandé, ce qui signifie que le thread de rendu ne bénéficiera que très peu de l'exécution du travail en parallèle.

Revenons sur la Timeline du cadre :

Cadre

Unity assure la synchronisation du pipeline en attendant le thread de rendu à chaque image. Ceci est nécessaire pour que le fil principal ne soit pas trop en avance sur ce qui est affiché à l'écran. Le thread de rendu est considéré comme ayant "fini de travailler" lorsqu'il a terminé le rendu et attend qu'une image apparaisse à l'écran. En d'autres termes, il attend que la mémoire tampon arrière soit retournée et devienne la mémoire tampon avant. Cependant, le fil de rendu ne se préoccupe pas de savoir quand l'image précédente a été affichée à l'écran - seul le fil principal s'en préoccupe parce qu'il doit s'autoalimenter. Ainsi, au lieu que le thread de rendu attende que le cadre apparaisse à l'écran, cette attente peut être déplacée vers le thread principal. Appelons-la WaitForLastPresentation(). La boucle principale devient :

while (!ShouldQuit())
{
  PumpOSMessages();
  UpdateInput();
  WaitForLastPresentation();
  SampleTime();
  Update();
  WaitForRenderThread();
  IssueRenderingCommands();
}

Le temps est maintenant échantillonné juste après la portion d'attente de la boucle, de sorte que le timing sera aligné sur le taux de rafraîchissement du moniteur. Le temps est également échantillonné au début de la trame, de sorte que Update() et Render() voient les mêmes temps.

Il est très important de noter que WaitForLastPresention() n'attend pas que le framen - 1 apparaisse à l'écran. Si c'était le cas, il n'y aurait pas de pipelining du tout. Au lieu de cela, il attend que framen - QualitySettings.maxQueuedFrames apparaisse à l'écran, ce qui permet au fil principal de continuer sans attendre la fin de la dernière image (sauf si maxQueuedFrames est défini à 1, auquel cas chaque image doit être terminée avant qu'une nouvelle ne commence).

Atteindre la stabilité : Nous devons aller plus loin !

Après la mise en œuvre de cette solution, le temps delta est devenu beaucoup plus stable qu'il ne l'était auparavant, mais une certaine gigue et une variance occasionnelle se sont encore produites. Nous dépendons du système d'exploitation qui réveille le moteur à temps. Cette opération peut prendre plusieurs microsecondes et donc introduire une gigue dans le temps delta, en particulier sur les plates-formes de bureau où plusieurs programmes sont exécutés en même temps.

Pour améliorer la synchronisation, vous pouvez utiliser l'horodatage exact d'une image présentée à l'écran (ou d'une mémoire tampon hors écran), que la plupart des API/plateformes graphiques vous permettent d'extraire. Par exemple, Direct3D 11 et 12 disposent de IDXGISwapChain::GetFrameStatistics, tandis que macOS fournit CVDisplayLink. Cette approche présente toutefois quelques inconvénients :

  • Vous devez écrire un code d'extraction distinct pour chaque API graphique prise en charge, ce qui signifie que le code de mesure du temps est désormais spécifique à la plate-forme et que chaque plate-forme dispose de sa propre implémentation. Étant donné que chaque plateforme se comporte différemment, un tel changement risque d'avoir des conséquences catastrophiques.
  • Avec certaines API graphiques, pour obtenir cet horodatage, la fonction VSync doit être activée. Cela signifie que si VSync est désactivé, l'heure doit toujours être calculée manuellement.

Toutefois, je pense que cette approche vaut la peine de prendre des risques et de déployer des efforts. Le résultat obtenu par cette méthode est très fiable et produit des timings qui correspondent directement à ce que l'on voit sur l'écran.

Puisque nous n'avons plus besoin d'échantillonner le temps nous-mêmes, les étapes WaitForLastPresention() et SampleTime() sont combinées en une nouvelle étape :

while (!ShouldQuit()) 
{ 
  PumpOSMessages(); 
  UpdateInput(); 
  WaitForLastPresentationAndGetTimestamp(); 
  Update(); 
  WaitForRenderThread(); 
  IssueRenderingCommands(); 
}

Le problème des mouvements saccadés est ainsi résolu.

Considérations sur la latence d'entrée

La latence d'entrée est un sujet délicat. Il n'est pas très facile de la mesurer avec précision et elle peut être introduite par différents facteurs : matériel d'entrée, système d'exploitation, pilotes, moteur de jeu, logique de jeu et affichage. Ici, je me concentre sur le facteur moteur de jeu de la latence d'entrée puisque Unity ne peut pas affecter les autres facteurs.

La latence d'entrée du moteur est le temps qui s'écoule entre le moment où le message OS d'entrée devient disponible et celui où l'image est envoyée à l'écran. Étant donné la boucle du thread principal, vous pouvez visualiser la latence d'entrée en tant que partie du code (en supposant que QualitySettings.maxQueuedFrames est réglé sur 2) :

PumpOSMessages(); // Pump input OS messages for frame 0
UpdateInput(); // Process input for frame 0
--------------------- // Earliest input event from the OS that didn't become part of frame 0 arrives here!
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -2 to appear on the screen
Update(); // Update game state for frame 0
WaitForRenderThread(); // Wait until all commands from frame -1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 0 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 1
UpdateInput(); // Process input for frame 1
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -1 to appear on the screen
Update(); // Update game state for frame 1, finally seeing the input event that arrived
WaitForRenderThread(); // Wait until all commands from frame 0 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 1 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 2
UpdateInput(); // Process input for frame 2
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 0 to appear on the screen
Update(); // Update game state for frame 2
WaitForRenderThread(); // Wait until all commands from frame 1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 2 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 3
UpdateInput(); // Process input for frame 3
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 1 to appear on the screen. This is where the changes from our input event appear.

Voilà, c'est fait ! Il se passe beaucoup de choses entre le moment où la saisie est disponible sous la forme d'un message du système d'exploitation et celui où le résultat est visible à l'écran. Si Unity ne perd pas d'images et que le temps passé par la boucle du jeu est principalement de l'attente par rapport au traitement, le pire scénario de latence d'entrée du moteur pour un taux de rafraîchissement de 144hz est de 4 * 6,94 = 27,76 ms, parce que nous attendons que les images précédentes apparaissent à l'écran quatre fois (ce qui signifie quatre intervalles de taux de rafraîchissement).

Vous pouvez améliorer la latence en pompant les événements du système d'exploitation et en mettant à jour les données après avoir attendu l'affichage de l'image précédente :

while (!ShouldQuit())
{
  WaitForLastPresentationAndGetTimestamp();
  PumpOSMessages();
  UpdateInput();
  Update();
  WaitForRenderThread();
  IssueRenderingCommands();
}

Cela élimine une attente de l'équation, et la latence d'entrée dans le pire des cas est maintenant de 3 * 6,94 = 20,82 ms.

Il est possible de réduire encore davantage la latence d'entrée en ramenant QualitySettings.maxQueuedFrames à 1 sur les plates-formes qui le permettent. La chaîne de traitement des entrées se présente alors comme suit :

--------------------- // Input event arrives from the OS!
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -2 to appear on the screen
PumpOSMessages(); // Pump input OS messages for frame 0
UpdateInput(); // Process input for frame 0
Update(); // Update game state for frame 0 with the input event that we are measuring
WaitForRenderThread(); // Wait until all commands from frame -1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 0 to the rendering thread
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 0 to appear on the screen. This is where the changes from our input event appear.

La latence d'entrée la plus défavorable est donc de 2 * 6,94 = 13,88 ms. C'est le niveau le plus bas que nous puissions atteindre lorsque nous utilisons VSync.

Avertissement : En réglant QualitySettings.maxQueuedFrames sur 1, vous désactivez le pipelining dans le moteur, ce qui rendra beaucoup plus difficile l'atteinte de votre taux de rafraîchissement cible. Gardez à l'esprit que si vous utilisez une fréquence d'images inférieure, votre latence d'entrée sera probablement pire que si vous maintenez QualitySettings.maxQueuedFrames à 2. Par exemple, s'il vous fait passer à 72 images par seconde, votre latence d'entrée sera de 2 * 1⁄72 = 27,8 ms, ce qui est pire que la latence précédente de 20,82 ms. Si vous souhaitez utiliser ce paramètre, nous vous suggérons de l'ajouter en tant qu'option dans le menu des paramètres de votre jeu afin que les joueurs disposant d'un matériel rapide puissent réduire QualitySettings.maxQueuedFrames, tandis que les joueurs disposant d'un matériel plus lent pourront conserver le paramètre par défaut.

Effets de VSync sur la latence d'entrée

La désactivation de VSync peut également contribuer à réduire la latence d'entrée dans certaines situations. Rappelons que la latence d'entrée est le temps qui s'écoule entre le moment où une entrée devient disponible dans le système d'exploitation et le moment où le cadre qui a traité l'entrée s'affiche à l'écran ou, sous la forme d'une équation mathématique, la latence d'entrée :

latence = tdisplay - tinput

Compte tenu de cette équation, il existe deux façons de réduire la latence d'entrée : abaisser tdisplay (pour que l'image soit affichée plus tôt) ou augmenter tinput (pour que les événements d'entrée soient interrogés plus tard).

L'envoi de données d'image du GPU à l'écran est extrêmement gourmand en données. Faites le calcul : pour envoyer une image 2560x1440 non HDR à l'écran 144 fois par seconde, il faut transmettre 12,7 gigabits chaque seconde (24 bits par pixel * 2560 * 1440 * 144). Ces données ne peuvent pas être transmises en un instant : le GPU transmet constamment des pixels à l'écran. Après la transmission de chaque trame, il y a une brève pause, puis la transmission de la trame suivante commence. Cette période d'interruption est appelée VBLANK. Lorsque VSync est activé, vous dites essentiellement au système d'exploitation de ne retourner le frame buffer que pendant VBLANK :

Cadre

Lorsque vous désactivez VSync, la mémoire tampon arrière est remplacée par la mémoire tampon avant dès que le rendu est terminé, ce qui signifie que l'écran commence soudainement à prendre des données de la nouvelle image au milieu de son cycle de rafraîchissement, ce qui fait que la partie supérieure de l'image provient de l'ancienne image et la partie inférieure de l'image provient de la nouvelle image :

Cadre

Ce phénomène est connu sous le nom de "déchirure". Le déchirement nous permet de réduire l'affichage pour la partie inférieure de l'image, en sacrifiant la qualité visuelle et la fluidité de l'animation au profit de la latence d'entrée. Ceci est particulièrement efficace lorsque la fréquence d'images du jeu est inférieure à l'intervalle VSync, ce qui permet une récupération partielle de la latence causée par une VSync manquée. Elle est également plus efficace dans les jeux où la partie supérieure de l'écran est occupée par l'interface utilisateur ou une skybox, ce qui rend le tearing plus difficile à percevoir.

La désactivation de VSync peut également contribuer à réduire la latence d'entrée en augmentant le débit. Si le jeu est capable d'effectuer un rendu à une fréquence d'images beaucoup plus élevée que la fréquence de rafraîchissement (par exemple, à 150 fps sur un écran de 60 Hz), la désactivation de VSync obligera le jeu à pomper les événements du système d'exploitation plusieurs fois au cours de chaque intervalle de rafraîchissement, ce qui réduira le temps moyen pendant lequel ils restent dans la file d'attente d'entrée du système d'exploitation en attendant d'être traités par le moteur.

Gardez à l'esprit que la désactivation de VSync doit être décidée par le joueur de votre jeu, car elle affecte la qualité visuelle et peut potentiellement provoquer des nausées si le déchirement est perceptible. La meilleure pratique consiste à fournir une option de paramétrage dans votre jeu pour l'activer ou la désactiver si elle est prise en charge par la plateforme.

Conclusion

Avec ce correctif, la chronologie des images d'Unity ressemble à ceci :

Cadre

Mais cela améliore-t-il réellement la fluidité du mouvement des objets ? Et comment !

Nous avons exécuté la démo Unity 2020.1 que nous avons montrée au début de ce billet dans Unity 2020.2.0b1. Voici la vidéo au ralenti qui en résulte :

Ce correctif est disponible dans la version bêta 2020.2 pour ces plateformes et API graphiques :

  • Windows, Xbox One, Universal Windows Platform (D3D11 et D3D12)
  • macOS, iOS, tvOS (Metal)
  • PlayStation 4
  • Switch

Nous prévoyons d'appliquer cette mesure aux autres plates-formes prises en charge dans un avenir proche.

Suivez ce fil de discussion pour les mises à jour et faites-nous savoir ce que vous pensez de notre travail jusqu'à présent.

Pour en savoir plus sur la synchronisation des cadres
Unity 2020.2 beta et au-delà
Aperçu de la version bêta

Si vous souhaitez en savoir plus sur ce qui est disponible dans la version 2020.2, consultez l'article de blog sur la version bêta et inscrivez-vous au webinaire sur la version bêta d'Unity 2020.2. Nous avons également partagé récemment notre feuille de route pour 2021.