Imaginez le problème: vous avez un jeu et vous en avez besoin pour fonctionner à 60 ips sur un moniteur à 60 Hz. Votre ordinateur est assez rapide pour le rendu et la mise à jour pour prendre un temps insignifiant, donc vous activez vsync et écrivez cette boucle de jeu:
while(running) { update(); render(); display(); }
Très simple! Maintenant, le jeu fonctionne à 60 images par seconde et tout se passe comme sur des roulettes. C'est fait. Merci d'avoir lu ce post.
Eh bien, évidemment, tout n'est pas si bon. Et si quelqu'un a un ordinateur faible qui ne peut pas rendre le jeu à une vitesse suffisante pour fournir 60 images par seconde? Et si quelqu'un achetait un de ces nouveaux moniteurs 144 hertz sympas? Et s'il a désactivé vsync dans les paramètres du pilote?
Vous pourriez penser: j'ai besoin de mesurer le temps quelque part et de fournir une mise à jour avec la bonne fréquence. C'est assez simple - il suffit d'accumuler du temps à chaque cycle et de mettre à jour chaque fois qu'il dépasse le seuil de 1/60 de seconde.
while(running) { deltaTime = CurrentTime()-OldTime; oldTime = CurrentTime(); accumulator += deltaTime; while(accumulator > 1.0/60.0){ update(); accumulator -= 1.0/60.0; } render(); display(); }
Fait, nulle part plus facile. En fait, il existe un tas de jeux dans lesquels le code ressemble essentiellement à cela. Mais c'est faux. Cela convient pour régler les horaires, mais entraîne des problèmes de secousses (bégaiement) et d'autres décalages. Un tel problème est très courant: les images ne sont pas affichées exactement 1/60 de seconde; même lorsque vsync est activé, il y a toujours un peu de bruit dans le temps où ils sont affichés (et dans la précision de la minuterie du système d'exploitation). Par conséquent, il y aura des situations où vous rendrez une image, et le jeu estime que le temps de la mise à jour n'est pas encore arrivé (car la batterie est en retard d'une petite fraction), donc il répète simplement la même image, mais maintenant le jeu est en retard pour l'image, donc il double mise à jour. Voici les contractions!
Sur Google, vous pouvez trouver plusieurs solutions prêtes à l'emploi pour éliminer ces contractions. Par exemple, un jeu peut utiliser une variable plutôt qu'un pas de temps constant, et simplement abandonner complètement les batteries dans le code temporel. Ou vous pouvez implémenter un pas de temps constant avec un rendu d'interpolation, décrit dans un article assez célèbre "
Fix Your Timestep " par Glenn Fielder. Ou vous pouvez refaire le code de la minuterie afin qu'il soit un peu plus flexible, comme décrit dans le post de Slick Entertainment's
Frame Timing Issues (malheureusement, ce blog n'est plus là).
Timings flous
La méthode Slick Entertainment avec des «timings flous» dans mon moteur était la plus facile à implémenter, car elle ne nécessitait pas de changements dans la logique du jeu et le rendu. Donc dans
The End is Nigh, je l'ai utilisé. Il suffisait juste de l'insérer dans le moteur. En fait, cela permet simplement de mettre à jour le jeu «un peu plus tôt» pour éviter les problèmes de décalage temporel. Si le jeu comprend vsync, il vous permet simplement d'utiliser vsync comme minuteur principal du jeu et fournit une image fluide.
Voici à quoi ressemble le code de mise à jour (le jeu "peut fonctionner" à 62 images par seconde, mais traite toujours chaque pas de temps comme s'il fonctionnait à 60 images par seconde. Je ne comprends pas très bien pourquoi le limiter afin que les valeurs de la batterie ne tombent pas en dessous de 0, mais sans ce code ne fonctionne pas). Vous pouvez l'interpréter de cette façon: "le jeu est mis à jour avec un pas fixe, s'il est rendu dans l'intervalle de 60fps à 62fps":
while(accumulator > 1.0/62.0){ update(); accumulator -= 1.0/60.0; if(accumulator < 0) accumulator = 0; }
Si vsync est activé, il permet essentiellement au jeu de fonctionner avec une hauteur fixe, qui correspond au taux de rafraîchissement du moniteur, et fournit une image fluide. Le principal problème ici est que lorsque vsync est désactivé, le jeu fonctionnera un
peu plus vite, mais la différence est si insignifiante que personne ne le remarquera.
Coureurs de vitesse. Les coureurs de vitesse le remarqueront. Peu de temps après la sortie du jeu, ils ont remarqué que certaines personnes sur les listes de meilleurs scores de speedran avaient des temps de voyage moins bons, mais cela s'est avéré meilleur que d'autres. Et la raison immédiate de cela était le timing imprécis et la déconnexion de vsync dans le jeu (ou les moniteurs 144 Hz). Par conséquent, il est devenu évident que vous devez désactiver ce flou lors de la déconnexion de vsync.
Oh, mais nous ne pouvons toujours pas vérifier si vsync est désactivé. Il n'y a aucun appel à cela dans le système d'exploitation, et bien que nous puissions demander à l'application d'activer ou de désactiver vsync, en fait, cela dépend complètement du système d'exploitation et du pilote graphique. La seule chose qui peut être faite est de rendre un ensemble d'images, d'essayer de mesurer le temps d'exécution de cette tâche, puis de comparer si elles prennent environ le même temps. C'est exactement ce que j'ai fait pour
The End is Nigh . Si le jeu n'inclut pas vsync avec une fréquence de 60 Hz, alors il revient à la temporisation d'image d'origine avec "60 images par seconde strictes". De plus, j'ai ajouté un paramètre au fichier de configuration qui oblige le jeu à ne pas utiliser de flou (principalement pour les coureurs de vitesse qui ont besoin de temps précis) et j'ai ajouté un gestionnaire de minuterie dans le jeu exact pour eux, ce qui permet d'utiliser l'échantillonneur automatique (c'est un script qui fonctionne avec une minuterie atomique).
Certains utilisateurs se plaignaient toujours des secousses occasionnelles de trames individuelles, mais elles semblaient si rares qu'elles pouvaient être expliquées par des événements du système d'exploitation ou d'autres raisons externes. Pas grave. Non?
En parcourant récemment mon code de minuterie, j'ai remarqué quelque chose d'étrange. La batterie a été déplacée, chaque image a pris un peu plus de 1/60 seconde, donc de temps en temps le jeu pensait qu'il était tard pour l'image et effectuait une double mise à jour. Il s'est avéré que mon moniteur fonctionne avec une fréquence de 59,94 Hz et non 60 Hz. Cela signifiait que tous les 1000 images, il devait effectuer une double mise à jour afin de «rattraper». Cependant, cela est très simple à corriger - il suffit de changer l'intervalle des fréquences de trame autorisées (pas de 60 à 62, mais de 59 à 61).
while(accumulator > 1.0/61.0){ update(); accumulator -= 1.0/59.0; if(accumulator < 0) accumulator = 0; }
Le problème décrit ci-dessus avec des moniteurs vsync et haute fréquence déconnectés persiste toujours, et la même solution s'applique à lui (restauration du temporisateur strict si le moniteur n'est
pas synchronisé vsync par 60).
Mais comment savoir si c'est la bonne solution? Comment s'assurer qu'il fonctionnera correctement sur toutes les combinaisons d'ordinateurs avec différents types de moniteurs, avec et sans vsync activé, etc.? Il est très difficile de garder une trace de tous ces problèmes de minuterie dans la tête et de comprendre ce qui cause la désynchronisation, les boucles étranges et autres.
Simulateur de moniteur
En essayant de trouver une solution fiable au «problème du moniteur 59,94 hertz», je me suis rendu compte que je ne pouvais pas simplement effectuer des vérifications d’essai et d’erreur, dans l’espoir de trouver une solution fiable. J'avais besoin d'un moyen pratique pour tester différentes tentatives d'écriture d'une minuterie de haute qualité et d'un moyen facile de vérifier si cela provoque une secousse ou un décalage temporel dans différentes configurations de moniteur.
Monitor Simulator apparaît sur la scène. C'est le code «sale et rapide» que j'ai écrit, simulant le «fonctionnement du moniteur», et me montrant essentiellement un tas de chiffres qui donnent une idée de la stabilité de chaque minuterie testée.
Par exemple, pour le minuteur le plus simple, les valeurs suivantes sont affichées depuis le début de l'article:
20211012021011202111020211102012012102012[...]
TOTAL UPDATES: 10001
TOTAL VSYNCS: 10002
TOTAL DOUBLE UPDATES: 2535
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.683
SYSTEM TIME: 166.7
Tout d'abord, le code affiche pour chaque vsync émulé le nombre du nombre de "mises à jour" du cycle de jeu après le vsync précédent. Toute valeur autre que solide 1 conduit à une image instable. À la fin, le code affiche les statistiques accumulées.
Lorsque vous utilisez la «minuterie floue» (avec un intervalle de 60 à 62 ips) sur un moniteur de 59,94 Hertz, le code affiche les éléments suivants:
111111111111111111111111111111111111111111111[...]
TOTAL UPDATES: 10000
TOTAL VSYNCS: 9991
TOTAL DOUBLE UPDATES: 10
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.667
SYSTEM TIME: 166.683
Les secousses du cadre sont très rares, il peut donc être difficile de le remarquer avec un tel nombre de 1. Mais les statistiques affichées montrent clairement que le jeu a effectué plusieurs doubles mises à jour ici, ce qui conduit à des secousses. Dans la version fixe (avec un intervalle de 59 à 61 ips), il y a 0 mises à jour ignorées ou doubles.
Vous pouvez également désactiver vsync. Le reste des données statistiques devient sans importance, mais cela me montre clairement l'ampleur du «décalage temporel» (le décalage temporel du système par rapport à l'emplacement du temps de jeu).
GAME TIME: 166.667
SYSTEM TIME: 169.102
C'est pourquoi lorsque vsync est désactivé, vous devez passer à une minuterie stricte, sinon ces écarts s'accumulent avec le temps.
Si je règle le temps de rendu sur .02 (c'est-à-dire que «plus d'un cadre» est nécessaire pour le rendu), j'obtiendrai un contraction. Idéalement, le modèle de jeu devrait ressembler à 202020202020, mais il est un peu inégal.
Dans cette situation, ce minuteur se comporte un peu mieux que le précédent, mais il devient plus déroutant et plus difficile de comprendre comment et pourquoi il fonctionne. Mais je peux simplement mettre les tests dans ce simulateur et vérifier leur comportement, et vous pourrez en comprendre les raisons plus tard. Essais et erreurs, bébé!
while(accumulator >= 1.0/61.0){ simulate_update(); accumulator -= 1.0/60.0; if(accumulator < 1.0/59.0–1.0/60.0) accumulator = 0; }
Vous pouvez télécharger
un simulateur de moniteur et vérifier indépendamment différentes méthodes de calcul de synchronisation.
Envoyez-moi un e-mail si vous trouvez quelque chose de mieux.
Je ne suis pas 100% satisfait de ma décision (cela nécessite toujours un hack avec «reconnaissance vsync» et des secousses occasionnelles peuvent se produire pendant la désynchronisation), mais je pense que c'est presque aussi bon qu'une tentative de mise en œuvre d'un cycle de jeu avec une étape fixe. Une partie de ce problème se pose car il est très difficile de déterminer les paramètres de ce qui est considéré comme «acceptable» ici. La principale difficulté réside dans le compromis entre décalage temporel et images doubles / sautées. Si vous exécutez un jeu à 60 Hz sur un moniteur PAL à 50 Hz ... quelle sera la bonne décision? Voulez-vous des secousses sauvages ou un jeu sensiblement plus lent? Les deux options semblent mauvaises.
Rendu séparé
Dans les méthodes précédentes, j'ai décrit ce que j'appelle le «rendu lockstep». Le jeu met à jour son état, puis le rend, et lors du rendu, il affiche toujours l'état le plus récent du jeu. Le rendu et la mise à jour sont connectés ensemble.
Mais vous pouvez les séparer. C'est exactement ce que fait la méthode décrite dans le message "
Fix Your Timestep ". Je ne vais pas me répéter, vous devriez certainement lire cet article. Ceci (si je comprends bien) est le «standard de l'industrie» utilisé dans les jeux et moteurs AAA tels que Unity et Unreal (cependant, dans les jeux actifs 2D intenses, ils préfèrent généralement utiliser une étape fixe (lockstep), car parfois la précision qui vous donne cette méthode).
Mais si nous décrivons brièvement le post de Glenn, il décrit simplement la méthode de mise à jour avec une fréquence d'images fixe, mais lors du rendu, l'interpolation est effectuée entre l'état "actuel" et "précédent" du jeu, et la valeur actuelle de la batterie est utilisée comme valeur d'interpolation. Avec cette méthode, vous pouvez effectuer un rendu à n'importe quelle fréquence d'images et mettre à jour le jeu à n'importe quelle fréquence, et l'image sera toujours fluide. Pas de secousses, fonctionne universellement.
while(running){ computeDeltaTimeSomehow(); accumulator += deltaTime; while(accumulator >= 1.0/60.0){ previous_state = current_state; current_state = update(); accumulator -= 1.0/60.0; } render_interpolated_somehow(previous_state, current_state, accumulator/(1.0/60.0)); display(); }
Donc, élémentaire. Le problème est résolu.
Maintenant, vous devez juste vous assurer que le jeu peut rendre les états interpolés ... mais attendez une minute, ce n'est vraiment pas facile du tout. Dans le post de Glenn, on suppose simplement que cela peut être fait. Il est assez facile de mettre en cache la position précédente de l'objet de jeu et d'interpoler ses mouvements, mais l'état du jeu est bien plus que cela. Il faut y prendre en compte l'état d'animation, la création et la destruction d'objets, et un tas de choses.
De plus, dans la logique du jeu, vous devez déterminer si l'objet est téléporté ou s'il doit être déplacé en douceur pour que l'interpolateur ne fasse pas de fausses hypothèses sur le chemin parcouru par l'objet de jeu jusqu'à sa position actuelle. Un vrai chaos peut se produire avec les virages, surtout si dans une image le virage d'un objet peut changer de plus de 180 degrés. Et comment traiter correctement les objets créés et détruits?
Pour le moment, je travaille juste sur cette tâche dans mon moteur. En fait, je viens d'interpoler les mouvements et de laisser tout le reste tel quel. Vous ne remarquerez pas de secousses si l'objet ne se déplace pas en douceur, donc ignorer les images d'animation et synchroniser la création / destruction de l'objet sur une image ne deviendra pas un problème si tout le reste est exécuté en douceur.
Cependant, il est étrange que, en fait, cette méthode rend le jeu dans un état en retard d'un état du jeu à partir duquel la simulation se trouve maintenant. Cela est discret, mais peut être connecté à d'autres sources de retards, par exemple, les retards d'entrée et les taux de rafraîchissement du moniteur, de sorte que ceux qui ont besoin du gameplay le plus réactif (je parle de vous, les coureurs de vitesse) préfèreront très probablement utiliser le lockstep dans le jeu.
Dans mon moteur, je donne juste un choix. Si vous avez un moniteur 60 hertz et un ordinateur rapide, il est préférable d'utiliser le verrouillage avec vsync activé. Si le moniteur a un taux de rafraîchissement non standard ou si votre ordinateur faible ne peut pas restituer constamment 60 images par seconde, activez l’interpolation d’images. Je veux appeler cette option «déverrouiller la fréquence d'images», mais les gens pourraient penser que cela signifie simplement «activer cette option si vous avez un bon ordinateur». Cependant, ce problème peut être résolu ultérieurement.
En fait, il
existe une méthode pour contourner ce problème.
Mises à jour à pas de temps variable
Beaucoup de gens m'ont demandé pourquoi ne pas simplement mettre à jour le jeu avec un pas de temps variable, et les programmeurs théoriques disent souvent: "si le jeu est écrit CORRECTEMENT, alors vous pouvez simplement le mettre à jour avec un pas de temps arbitraire".
while(running) { deltaTime = CurrentTime()-OldTime; oldTime = CurrentTime(); update(deltaTime); render(); display(); }
Pas de bizarreries avec les horaires. Pas de rendu d'interpolation bizarre. Tout est simple, tout fonctionne.
Donc, élémentaire. Le problème est résolu. Et maintenant pour toujours! Il est impossible d'obtenir un meilleur résultat!
Maintenant, c'est assez simple pour faire fonctionner la logique du jeu avec un pas de temps arbitraire. C'est simple, il suffit de remplacer tout ce code:
position += speed;
à ce sujet:
position += speed * deltaTime;
et remplacez le code suivant:
speed += acceleration; position += speed;
à ce sujet:
speed += acceleration * deltaTime; position += speed * deltaTime;
et remplacez le code suivant:
speed += acceleration; speed *= friction; position += speed;
à ce sujet:
Vec3D p0 = position; Vec3D v0 = velocity; Vec3D a = acceleration*(1.0/60.0); double f = friction; double n = dt*60; double fN = pow(friction, n); position = p0 + ((f*(a*(f*fN-f*(n+1)+n)+(f-1)*v0*(fN-1)))/((f-1)*(f-1)))*(1.0/60.0); velocity = v0*fN+a*(f*(fN-1)/(f-1));
... alors attendez
D'où tout cela vient-il?
La dernière partie est littéralement copiée du code auxiliaire de mon moteur, qui effectue "un mouvement indépendant de la fréquence d'images vraiment correct avec une vitesse limitant le frottement". Il y a un peu d'ordures dedans (ces multiplications et divisions par 60). Mais c'est la version «correcte» du code avec un pas de temps variable pour le fragment précédent. Je l'ai compris pendant plus d'une heure avec
Wolfram Alpha .
Maintenant, ils peuvent me demander pourquoi ne pas le faire comme ceci:
speed += acceleration * deltaTime; speed *= pow(friction, deltaTime); position += speed * deltaTime;
Et même si cela semble fonctionner, c'est en fait mal de le faire. Vous pouvez le vérifier vous-même. Effectuez deux mises à jour avec deltaTime = 1, puis effectuez une mise à jour avec deltaTime = 2, et les résultats seront différents. Habituellement, nous nous efforçons pour que le jeu fonctionne de concert, de sorte que de telles différences ne sont pas les bienvenues. C'est probablement une assez bonne solution, si vous savez avec certitude que deltaTime est toujours approximativement égal à une valeur, mais alors vous devez écrire du code pour vous assurer que les mises à jour sont effectuées à une fréquence constante et ... oui. C'est vrai, maintenant nous essayons de tout faire "CORRECTEMENT".
Si un si petit morceau de code se déroule en calculs mathématiques monstrueux, alors imaginez des modèles de mouvement plus complexes auxquels participent de nombreux objets en interaction, etc. Vous pouvez maintenant voir clairement que la «bonne» solution est irréalisable. Le maximum que nous pouvons atteindre est une «approximation grossière». Oublions cela pour l'instant, et supposons que nous ayons en fait une version «vraiment correcte» des fonctions de mouvement. Super, non?
Non, en fait. Voici un exemple réel du problème que j'ai eu avec ça à
Bombernauts . Un joueur peut faire rebondir environ 1 tuile, et le jeu se déroule dans une grille de blocs en 1 tuile. Pour atterrir sur un bloc, les jambes du personnage doivent s'élever au-dessus de la surface supérieure du bloc.
Mais comme la reconnaissance des collisions est effectuée ici avec une étape discrète, alors si le jeu fonctionne avec une faible fréquence d'images, les jambes n'atteindront pas parfois la surface de la tuile, bien qu'elles aient suivi la même courbe de mouvement, et au lieu de soulever, le joueur glissera du mur.
De toute évidence, ce problème est résoluble. Mais il illustre les types de problèmes que nous rencontrons en essayant d'implémenter correctement le travail du cycle de jeu avec un pas de temps variable. Nous perdons de la cohérence et du déterminisme, nous devons donc nous débarrasser des fonctions de relecture du jeu en enregistrant les entrées du joueur, le multijoueur déterministe, etc. Pour les jeux 2D rapides basés sur les réflexes, la cohérence est extrêmement importante (et bonjour encore aux coureurs de vitesse).
Si vous essayez d'ajuster les pas de temps de sorte qu'ils ne soient ni trop grands ni trop petits, vous perdrez le principal avantage obtenu du pas de temps variable et vous pourrez utiliser en toute sécurité les deux autres méthodes décrites ici. Le jeu ne vaut pas la chandelle. Trop d'efforts supplémentaires seront mis dans la logique du jeu (la mise en œuvre des mathématiques correctes du mouvement), et trop de victimes seront nécessaires dans le domaine du déterminisme et de la cohérence. Je n'utiliserais cette méthode que pour un jeu de rythme musical (dans lequel les équations du mouvement sont simples et nécessitent un maximum de réactivité et de fluidité). Dans tous les autres cas, je choisirai une mise à jour fixe.
Conclusion
Vous savez maintenant comment faire fonctionner le jeu à une fréquence constante de 60 images par seconde. C'est trivialement simple, et personne d'autre ne devrait avoir de problème avec ça.
Aucun autre problème ne complique cette tâche.