Hay dos cosas cruciales para que el movimiento parezca suave, el primero es obviamente que lo que renderiza debe coincidir con el estado esperado en el momento en que se presenta el marco al usuario, el segundo es que debe presentar los marcos al usuario en un intervalo relativamente fijo. Presentar un cuadro a T + 10ms, luego otro a T + 30ms, luego otro a T + 40ms, parecerá que el usuario está juzgando, incluso si lo que realmente se muestra para esos momentos es correcto de acuerdo con la simulación.
Su bucle principal parece carecer de cualquier mecanismo de activación para asegurarse de que solo se procesa a intervalos regulares. Entonces, a veces puede hacer 3 actualizaciones entre renders, a veces puede hacer 4. Básicamente, su bucle se renderizará con la mayor frecuencia posible, tan pronto como haya simulado el tiempo suficiente para impulsar el estado de simulación antes de la hora actual, entonces renderiza ese estado. Pero cualquier variabilidad en cuanto al tiempo que lleva actualizar o renderizar, y el intervalo entre cuadros también variará. Tienes un paso de tiempo fijo para tu simulación, pero un paso de tiempo variable para tu renderizado.
Lo que probablemente necesite es esperar justo antes de su renderizado, lo que garantiza que solo comience a renderizar al comienzo de un intervalo de renderizado. Idealmente, eso debería ser adaptativo: si ha tardado demasiado en actualizar / renderizar y el inicio del intervalo ya ha pasado, debe renderizar de inmediato, pero también aumentar la duración del intervalo, hasta que pueda renderizar y actualizar constantemente y aún así llegar a el siguiente render antes de que finalice el intervalo. Si tiene tiempo de sobra, puede reducir lentamente el intervalo (es decir, aumentar la velocidad de fotogramas) para volver a procesar más rápido.
Pero, y aquí está el truco, si no renderiza el marco inmediatamente después de detectar que el estado de simulación se ha actualizado a "ahora", entonces introduce un alias temporal. El marco que se presenta al usuario se presenta un poco en el momento equivocado, y eso en sí mismo se sentirá como un tartamudeo.
Esta es la razón del "paso de tiempo parcial" que verá mencionado en los artículos que ha leído. Está ahí por una buena razón, y eso es porque a menos que fije su paso de tiempo de física a algún múltiplo integral fijo de su paso de tiempo de renderizado fijo, simplemente no puede presentar los cuadros en el momento correcto. Terminas presentándolos demasiado temprano o demasiado tarde. La única forma de obtener una tasa de representación fija y aún presentar algo que es físicamente correcto, es aceptar que en el momento en que se produce el intervalo de representación, lo más probable es que esté a medio camino entre dos de sus pasos de tiempo de física fija. Pero eso no significa que los objetos se modifiquen durante el renderizado, solo que el renderizado tiene que establecer temporalmente dónde están los objetos para poder representarlos en algún lugar entre donde estaban antes y dónde están después de la actualización. Eso es importante: nunca cambie el estado mundial para la representación, solo las actualizaciones deberían cambiar el estado mundial.
Entonces, para ponerlo en un bucle de pseudocódigo, creo que necesitas algo más como:
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);
}
Para que esto funcione, todos los objetos que se actualizan necesitan preservar el conocimiento de dónde estaban antes y dónde están ahora, para que la representación pueda usar su conocimiento de dónde está el objeto.
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);
}
}
Y establezcamos una línea de tiempo en milisegundos, diciendo que el renderizado tarda 3 ms en completarse, la actualización tarda 1 ms, su paso de tiempo de actualización se fija en 5 ms, y su paso de tiempo de renderizado comienza (y permanece) a 16 ms [60Hz].
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
- Primero iniciamos en el tiempo 0 (entonces currentTime = 0)
- Representamos con una proporción de 1.0 (100% currentTime), que dibujará el mundo en el momento 0
- Cuando eso termine, el tiempo real es 3, y no esperamos que el marco termine hasta las 16, por lo que debemos ejecutar algunas actualizaciones
- T + 3: Actualizamos de 0 a 5 (por lo tanto, currentTime = 5, previousTime = 0)
- T + 4: aún antes del final del marco, por lo que actualizamos de 5 a 10
- T + 5: aún antes del final del marco, por lo que actualizamos de 10 a 15
- T + 6: todavía antes del final del marco, por lo que actualizamos de 15 a 20
- T + 7: aún antes del final del marco, pero currentTime está justo más allá del final del marco. No queremos simular más porque hacerlo nos empujaría más allá del tiempo que queremos renderizar. En cambio, esperamos en silencio el próximo intervalo de renderizado (16)
- T + 16: es hora de renderizar de nuevo. previousTime es 15, currentTime es 20. Entonces, si queremos renderizar en T + 16, estamos a 1 ms del camino de 5 ms. Entonces estamos a 20% del camino a través del marco (proporción = 0.2). Cuando renderizamos, dibujamos objetos el 20% del camino entre su posición anterior y su posición actual.
- Vuelva a 3. y continúe indefinidamente.
Aquí hay otro matiz acerca de simular con demasiada anticipación, lo que significa que las entradas del usuario pueden ignorarse a pesar de que ocurrieron antes de que se procesara el marco, pero no se preocupe por eso hasta que esté seguro de que el bucle está simulando sin problemas.