Comment l'interpolation fonctionne-t-elle réellement pour lisser le mouvement d'un objet?

10

J'ai posé quelques questions similaires au cours des 8 derniers mois sans joie réelle, donc je vais rendre la question plus générale.

J'ai un jeu Android qui est OpenGL ES 2.0. en son sein, j'ai la boucle de jeu suivante:

Ma boucle fonctionne sur un principe de pas de temps fixe (dt = 1 / ticksPerSecond )

loops=0;

    while(System.currentTimeMillis() > nextGameTick && loops < maxFrameskip){

        updateLogic(dt);
        nextGameTick+=skipTicks;
        timeCorrection += (1000d/ticksPerSecond) % 1;
        nextGameTick+=timeCorrection;
        timeCorrection %=1;
        loops++;

    }

    render();   

Mon intégration fonctionne comme ceci:

sprite.posX+=sprite.xVel*dt;
sprite.posXDrawAt=sprite.posX*width;

Maintenant, tout fonctionne à peu près comme je le voudrais. Je peux spécifier que je voudrais qu'un objet se déplace sur une certaine distance (largeur d'écran par exemple) en 2,5 secondes et il fera exactement cela. De plus, en raison du saut de trame que j'autorise dans ma boucle de jeu, je peux le faire sur à peu près n'importe quel appareil et cela prendra toujours 2,5 secondes.

Problème

Cependant, le problème est que lorsqu'un cadre de rendu saute, le graphique bégaie. C'est extrêmement ennuyeux. Si je supprime la possibilité de sauter des images, tout est fluide comme vous le souhaitez, mais fonctionnera à différentes vitesses sur différents appareils. Ce n'est donc pas une option.

Je ne sais toujours pas pourquoi le cadre saute, mais je voudrais souligner que cela n'a rien à voir avec de mauvaises performances , j'ai repris le code à 1 petit sprite et sans logique (à part la logique requise pour déplacer le sprite) et je reçois toujours des images sautées. Et c'est sur une tablette Google Nexus 10 (et comme mentionné ci-dessus, j'ai besoin de sauter des trames pour garder la vitesse constante sur tous les appareils).

Donc, la seule autre option que j'ai est d'utiliser l'interpolation (ou l'extrapolation), j'ai lu tous les articles, mais aucun ne m'a vraiment aidé à comprendre comment cela fonctionne et toutes mes implémentations tentées ont échoué.

En utilisant une méthode, j'ai pu faire bouger les choses en douceur, mais c'était impossible car cela a gâché ma collision. Je peux prévoir le même problème avec n'importe quelle méthode similaire parce que l'interpolation est passée à (et appliquée dans) la méthode de rendu - au moment du rendu. Donc, si Collision corrige la position (le personnage se trouve maintenant juste à côté du mur), le rendu peut modifier sa position et la dessiner dans le mur.

Je suis donc vraiment confus. Les gens ont dit que vous ne devriez jamais modifier la position d'un objet depuis la méthode de rendu, mais tous les exemples en ligne le montrent.

Je demande donc un coup de pouce dans la bonne direction, veuillez ne pas créer de lien vers les articles populaires sur la boucle de jeu (deWitters, Fix your timestep, etc.) car j'ai lu ces plusieurs fois . Je ne demande à personne d'écrire mon code pour moi. Expliquez simplement en termes simples comment l'interpolation fonctionne réellement avec quelques exemples. Je vais ensuite essayer d'intégrer toutes les idées dans mon code et poser des questions plus spécifiques si besoin est. (Je suis sûr que c'est un problème avec lequel beaucoup de gens luttent).

Éditer

Quelques informations supplémentaires - variables utilisées dans la boucle de jeu.

private long nextGameTick = System.currentTimeMillis();
//loop counter
private int loops;
//Amount of frames that we will allow app to skip before logic is affected
private final int maxFrameskip = 5;                         
//Game updates per second
final int ticksPerSecond = 60;
//Amount of time each update should take        
private final int skipTicks = (1000 / ticksPerSecond);
float dt = 1f/ticksPerSecond;
private double timeCorrection;
BungleBonce
la source
Et la raison du downvote est ...................?
BungleBonce
1
Impossible de dire parfois. Cela semble avoir tout ce qu'une bonne question devrait avoir lorsque vous essayez de résoudre un problème. Extrait de code concis, explications de ce que vous avez essayé, tentatives de recherche et explication claire de votre problème et de ce que vous devez savoir.
Jesse Dorsey
Je n'étais pas votre downvote, mais veuillez clarifier une partie. Vous dites que les graphiques bégaient quand un cadre est sauté. Cela semble être une déclaration évidente (un cadre est manqué, il semble qu'un cadre soit manqué). Alors, pouvez-vous mieux expliquer le saut? Est-ce que quelque chose de plus étrange se produit? Sinon, cela pourrait être un problème insoluble, car vous ne pouvez pas obtenir un mouvement fluide si le taux de rafraîchissement baisse.
Seth Battin
Merci, Noctrine, ça me dérange vraiment quand les gens votent sans laisser d'explication. @SethBattin, désolé, oui bien sûr, vous avez raison, le saut de trame est à l'origine de la secousse, cependant, l'interpolation devrait trier cela, comme je le dis ci-dessus, j'ai eu un succès (mais limité). Si je me trompe, je suppose que la question serait, comment puis-je le faire fonctionner correctement à la même vitesse sur différents appareils?
BungleBonce
4
Relisez attentivement ces documents. Ils ne modifient pas réellement l'emplacement de l'objet dans la méthode de rendu. Ils modifient uniquement l'emplacement apparent de la méthode en fonction de sa dernière position et de sa position actuelle en fonction du temps écoulé.
AttackingHobo

Réponses:

5

Il y a deux choses cruciales pour que le mouvement paraisse fluide, la première est évidemment que ce que vous effectuez doit correspondre à l'état attendu au moment où l'image est présentée à l'utilisateur, la seconde est que vous devez présenter les images à l'utilisateur à un intervalle relativement fixe. Présenter une trame à T + 10 ms, puis une autre à T + 30 ms, puis une autre à T + 40 ms, apparaîtra à l'utilisateur comme saccadé, même si ce qui est réellement affiché pour ces temps est correct selon la simulation.

Votre boucle principale semble ne pas avoir de mécanisme de déclenchement pour vous assurer que vous effectuez uniquement le rendu à intervalles réguliers. Donc, parfois, vous pouvez faire 3 mises à jour entre les rendus, parfois vous pouvez en faire 4. Fondamentalement, votre boucle sera rendue aussi souvent que possible, dès que vous aurez simulé suffisamment de temps pour pousser l'état de simulation devant l'heure actuelle, vous aurez puis rendez cet état. Mais toute variabilité du temps de mise à jour ou de rendu et l'intervalle entre les images varient également. Vous avez un pas de temps fixe pour votre simulation, mais un pas de temps variable pour votre rendu.

Ce dont vous avez probablement besoin, c'est d'une attente juste avant votre rendu, ce qui garantit que vous ne commencerez jamais le rendu qu'au début d'un intervalle de rendu. Idéalement, cela devrait être adaptatif: si vous avez mis trop de temps à mettre à jour / rendre et que le début de l'intervalle est déjà passé, vous devez rendre immédiatement, mais aussi augmenter la longueur de l'intervalle, jusqu'à ce que vous puissiez rendre et mettre à jour de manière cohérente et toujours accéder à le rendu suivant avant la fin de l'intervalle. Si vous avez beaucoup de temps à perdre, vous pouvez réduire lentement l'intervalle (c.-à-d. Augmenter la fréquence d'images) pour rendre à nouveau plus rapidement.

Mais, et voici le kicker, si vous ne restituez pas l'image immédiatement après avoir détecté que l'état de simulation a été mis à jour "maintenant", vous introduisez un alias temporel. Le cadre présenté à l'utilisateur est présenté au mauvais moment, et cela se sentira comme un bégaiement.

C'est la raison du «pas de temps partiel» que vous verrez mentionné dans les articles que vous avez lus. C'est là pour une bonne raison, et c'est parce que si vous ne fixez pas votre pas de temps physique à un multiple entier fixe de votre pas de temps de rendu fixe, vous ne pouvez tout simplement pas présenter les images au bon moment. Vous finissez par les présenter trop tôt ou trop tard. La seule façon d'obtenir un taux de rendu fixe et de toujours présenter quelque chose de physiquement correct est d'accepter qu'au moment où l'intervalle de rendu arrive, vous serez très probablement à mi-chemin entre deux de vos pas de temps fixes en physique. Mais cela ne signifie pas que les objets sont modifiés lors du rendu, juste que le rendu doit établir temporairement où se trouvent les objets pour qu'il puisse les rendre quelque part entre où ils étaient avant et où ils se trouvent après la mise à jour. C'est important - ne changez jamais l'état du monde pour le rendu, seules les mises à jour devraient changer l'état du monde.

Donc, pour le mettre dans une boucle de pseudocode, je pense que vous avez besoin de quelque chose de plus comme:

InitialiseWorldState();

previousTime = currentTime = 0.0;
renderInterval = 1.0 / 60.0; //A nice high starting interval

subFrameProportion = 1.0; //100% currentFrame, 0% previousFrame

while (true)
{
    frameStart = ActualTime();

    //Render the world state as if it was some proportion 
    // between previousTime and currentTime
    // E.g. if subFrameProportion is 0.5, previousTime is 0.1 and 
    // currentTime is 0.2, then we actually want to render the state
    // as it would be at time 0.15. We'd do that by interpolating 
    // between movingObject.previousPosition and movingObject.currentPosition
    // with a lerp parameter of 0.5
    Render(subFrameProportion); 

    //Check we've not taken too long and missed our render interval
    frameTime = ActualTime() - frameStart;
    if (frameTime > renderInterval)
    {
        renderInterval = frameTime * 1.2f; //Give us a more reasonable render interval that we actually have a chance of hitting
    }

    expectedFrameEnd = frameStart + renderInterval;

    //Loop until it's time to render the next frame
    while (ActualTime() < expectedFrameEnd)
    {
        //step the simulation forward until it has moved just beyond the frame end
        if (previousTime < expectedFrameEnd) &&
            currentTime >= expectedFrameEnd)
        {
            previousTime = currentTime;

            Update();
            currentTime += fixedTimeStep;

            //After the update, all objects will be in the position they should be for
            // currentTime, **but** they also need to remember where they were before,
            // so that the rendering can draw them somewhere between previousTime and
            //  currentTime

            //Check again we've not taken too long and missed our render interval
            frameTime = ActualTime() - frameStart;
            if (frameTime > renderInterval)
            {
                renderInterval = frameTime * 1.2f; //Give us a more reasonable render interval that we actually have a chance of hitting
                expectedFrameEnd = frameStart + renderInterval
            }
        }
        else
        {
            //We've brought the simulation to just after the next time
            // we expect to render, so we just want to wait.
            // Ideally sleep or spin in a tight loop while waiting.
            timeTillFrameEnd = expectedFrameEnd - ActualTime();
            sleep(timeTillFrameEnd);
        }
    }

    //How far between update timesteps (i.e. previousTime and currentTime)
    // will we be at the end of the frame when we start the next render?
    subFrameProportion = (expectedFrameEnd - previousTime) / (currentTime - previousTime);
}

Pour que cela fonctionne, tous les objets mis à jour doivent conserver la connaissance de leur emplacement précédent et de leur emplacement actuel, afin que le rendu puisse utiliser sa connaissance de l'emplacement de l'objet.

class MovingObject
{
    Vector velocity;
    Vector previousPosition;
    Vector currentPosition;

    Initialise(startPosition, startVelocity)
    {
        currentPosition = startPosition; // position at time 0
        velocity = startVelocity;
        //ignore previousPosition because we should never render before time 0
    }

    Update()
    {
        previousPosition = currentPosition;
        currentPosition += velocity * fixedTimeStep;
    }

    Render(subFrameProportion)
    {
        Vector actualPosition = 
            Lerp(previousPosition, currentPosition, subFrameProportion);
        RenderAt(actualPosition);
    }
}

Et définissons une chronologie en millisecondes, en disant que le rendu prend 3 ms pour terminer, la mise à jour prend 1 ms, votre pas de temps de mise à jour est fixé à 5 ms et votre pas de temps de rendu commence (et reste) à 16 ms [60 Hz].

0   1   2   3   4   5   6   7   8   9   10  11  12  13  14  15  16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31  32  33
R0          U5  U10 U15 U20 W16                                 R16         U25 U30 U35 W32                                 R32
  1. On initialise d'abord au temps 0 (donc currentTime = 0)
  2. Nous rendons avec une proportion de 1.0 (100% currentTime), ce qui dessinera le monde au temps 0
  3. Lorsque cela se termine, le temps réel est de 3, et nous ne nous attendons pas à ce que le cadre se termine avant 16, nous devons donc exécuter certaines mises à jour
  4. T + 3: Nous mettons à jour de 0 à 5 (donc après currentTime = 5, previousTime = 0)
  5. T + 4: toujours avant la fin du cadre, nous mettons donc à jour de 5 à 10
  6. T + 5: toujours avant la fin du cadre, nous mettons donc à jour de 10 à 15
  7. T + 6: toujours avant la fin du cadre, nous mettons donc à jour de 15 à 20
  8. T + 7: toujours avant la fin de la trame, mais currentTime est juste au-delà de la fin de la trame. Nous ne voulons pas simuler davantage car cela nous pousserait au-delà du temps que nous voulons ensuite rendre. Au lieu de cela, nous attendons tranquillement le prochain intervalle de rendu (16)
  9. T + 16: Il est temps de rendre à nouveau. previousTime est 15, currentTime est 20. Donc, si nous voulons rendre à T + 16, nous sommes à 1 ms du chemin à travers le pas de temps de 5 ms. Nous sommes donc à 20% du chemin à travers le cadre (proportion = 0,2). Lorsque nous effectuons le rendu, nous dessinons des objets à 20% entre leur position précédente et leur position actuelle.
  10. Retournez à 3. et continuez indéfiniment.

Il y a une autre nuance ici à propos de la simulation trop à l'avance, ce qui signifie que les entrées de l'utilisateur peuvent être ignorées même si elles se sont produites avant que le cadre ne soit réellement rendu, mais ne vous inquiétez pas jusqu'à ce que vous soyez sûr que la boucle se simule correctement.

MrCranky
la source
NB: le pseudocode est faible de deux manières. Premièrement, il n'attrape pas le cas de la spirale de la mort (cela prend plus de temps que fixedTimeStep pour mettre à jour, ce qui signifie que la simulation prend toujours plus de retard, en fait une boucle infinie), deuxièmement, le renderInterval n'est plus jamais raccourci. En pratique, vous souhaitez augmenter immédiatement l'intervalle de rendu, mais au fil du temps, le raccourcir progressivement du mieux que vous le pouvez, dans une certaine tolérance du temps d'image réel. Sinon, une mauvaise / longue mise à jour vous mettra à jamais avec un faible taux de rafraîchissement.
MrCranky
Merci pour ce @MrCranky, en effet, je me bats depuis des lustres sur la façon de «limiter» le rendu dans ma boucle! Je ne pouvais tout simplement pas savoir comment le faire et je me demandais si cela pouvait être l'un des problèmes. Je vais avoir une bonne lecture de cela et donner un essai à vos suggestions, j'en ferai rapport! Merci encore :-)
BungleBonce
Merci @MrCranky, OK, j'ai lu et relu votre réponse mais je ne la comprends pas :-( J'ai essayé de l'implémenter mais cela m'a juste donné un écran vide. J'ai vraiment du mal avec ça. PreviousFrame et currentFrame je suppose se rapporte aux positions précédentes et actuelles de mes objets en mouvement? Et qu'en est-il de la ligne "currentFrame = Update ();" - Je ne reçois pas cette ligne, cela signifie-t-il appeler update (); comme je ne vois pas où sinon j'appelle update? Ou cela signifie-t-il simplement de mettre currentFrame (position) à sa nouvelle valeur? Merci encore pour votre aide !!
BungleBonce
Oui, effectivement. La raison pour laquelle j'ai mis previousFrame et currentFrame en tant que valeurs de retour de Update et InitialiseWorldState est parce que pour permettre au rendu de dessiner le monde car il est à mi-chemin entre deux étapes de mise à jour fixes, vous devez avoir non seulement la position actuelle de chaque l'objet que vous souhaitez dessiner, mais aussi leurs positions précédentes. Vous pouvez demander à chaque objet d'enregistrer les deux valeurs en interne, ce qui devient compliqué.
MrCranky
Mais il est également possible (mais beaucoup plus difficile) d'architecturer les choses de sorte que toutes les informations d'état nécessaires pour représenter l'état actuel du monde au temps T soient conservées sous un seul objet. Conceptuellement, c'est beaucoup plus clair lorsque vous expliquez quelles informations se trouvent dans le système, car vous pouvez traiter l'état du cadre comme quelque chose produit par une étape de mise à jour, et conserver le cadre précédent consiste simplement à conserver un de ces objets d'état du cadre. Cependant, je pourrais réécrire la réponse pour être un peu plus comme si vous l'implémenteriez probablement.
MrCranky
3

Ce que tout le monde vous a dit est correct. Ne mettez jamais à jour la position de simulation de votre sprite dans votre logique de rendu.

Pensez-y comme ceci, votre sprite a 2 positions; où la simulation indique qu'il est à la dernière mise à jour de la simulation et où le sprite est rendu. Ce sont deux coordonnées complètement différentes.

Le sprite est rendu à sa position extrapolée. La position extrapolée est calculée à chaque image de rendu, utilisée pour rendre l'image-objet, puis jetée. C'est tout ce qu'on peut en dire.

À part cela, vous semblez avoir une bonne compréhension. J'espère que cela t'aides.

William Morrison
la source
Excellent @WilliamMorrison - merci d'avoir confirmé cela, je n'étais jamais vraiment sûr à 100% que c'était le cas, je pense maintenant que je suis sur la bonne voie pour que cela fonctionne dans une certaine mesure - bravo!
BungleBonce
Juste curieux @WilliamMorrison, en utilisant ces coordonnées à jeter, comment pourrait-on atténuer le problème des sprites étant dessinés `` intégrés '' ou `` juste au-dessus '' d'autres objets - l'exemple évident, étant des objets solides dans un jeu 2D. Devez-vous également exécuter votre code de collision au moment du rendu?
BungleBonce
Dans mes jeux, oui, c'est ce que je fais. Soyez meilleur que moi, ne faites pas ça, ce n'est pas la meilleure solution. Il complique le code de rendu avec une logique qu'il ne devrait pas utiliser et gaspillera le processeur sur la détection de collision redondante. Il serait préférable d'interpoler entre l'avant-dernière position et la position actuelle. Cela résout le problème car vous n'extrapolez pas vers une mauvaise position, mais complique les choses lorsque vous effectuez un pas de retard sur la simulation. J'adore entendre votre opinion, l'approche que vous adoptez et vos expériences.
William Morrison
Oui, c'est un problème délicat à résoudre. J'ai posé une question distincte à ce sujet ici gamedev.stackexchange.com/questions/83230/… si vous voulez garder un œil dessus ou contribuer quelque chose. Maintenant, ce que vous avez suggéré dans votre commentaire, ne le fais-je pas déjà? (Interpolation entre la trame précédente et la trame actuelle)?
BungleBonce
Pas assez. Vous extrapolez actuellement. Vous prenez les données les plus récentes de la simulation et extrapolez à quoi ressemblent ces données après des pas de temps fractionnaires. Je vous suggère d'interpoler entre la dernière position de simulation et la position de simulation actuelle par pas de temps fractionnaires pour le rendu à la place. Le rendu sera derrière la simulation par 1 pas de temps. Cela garantit que vous ne rendrez jamais un objet dans un état que la simulation n'a pas validé (c'est-à-dire qu'un projectile n'apparaîtra pas dans un mur sauf si la simulation échoue.)
William Morrison