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
- On initialise d'abord au temps 0 (donc currentTime = 0)
- Nous rendons avec une proportion de 1.0 (100% currentTime), ce qui dessinera le monde au temps 0
- 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
- T + 3: Nous mettons à jour de 0 à 5 (donc après currentTime = 5, previousTime = 0)
- T + 4: toujours avant la fin du cadre, nous mettons donc à jour de 5 à 10
- T + 5: toujours avant la fin du cadre, nous mettons donc à jour de 10 à 15
- T + 6: toujours avant la fin du cadre, nous mettons donc à jour de 15 à 20
- 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)
- 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.
- 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.