Há duas coisas cruciais para que o movimento pareça suave: a primeira é obviamente que o que você renderiza precisa corresponder ao estado esperado no momento em que o quadro é apresentado ao usuário; o segundo é que você precisa apresentar quadros ao usuário em um intervalo relativamente fixo. Apresentar um quadro em T + 10ms, depois outro em T + 30ms e outro em T + 40ms, parecerá que o usuário está tremendo, mesmo que o que é realmente mostrado para esses tempos esteja correto de acordo com a simulação.
Parece que seu loop principal não possui nenhum mecanismo de bloqueio para garantir que você renderize apenas em intervalos regulares. Então, às vezes você pode fazer três atualizações entre renderizações, às vezes você pode fazer 4. Basicamente, seu loop será renderizado o mais rápido possível, assim que você tiver simulado tempo suficiente para enviar o estado da simulação à frente do horário atual, então renderize esse estado. Mas qualquer variabilidade em quanto tempo leva para atualizar ou renderizar, e o intervalo entre os quadros também varia. Você tem um timestep fixo para sua simulação, mas um timestep variável para sua renderização.
O que você provavelmente precisa é de uma espera antes da renderização, para garantir que você só comece a renderizar no início de um intervalo de renderização. Idealmente, isso deve ser adaptável: se você demorou muito para atualizar / renderizar e o início do intervalo já passou, renderize imediatamente, mas também aumente a duração do intervalo, até que você possa renderizar e atualizar consistentemente e ainda assim conseguir a próxima renderização antes do intervalo terminar. Se você tiver bastante tempo de sobra, poderá reduzir lentamente o intervalo (ou seja, aumentar a taxa de quadros) para renderizar mais rapidamente novamente.
Mas, e aqui está o kicker, se você não renderizar o quadro imediatamente após detectar que o estado da simulação foi atualizado para "agora", introduza o aliasing temporal. O quadro que está sendo apresentado ao usuário está sendo apresentado levemente na hora errada, e isso por si só parecerá uma gagueira.
Esse é o motivo do "intervalo de tempo parcial" que você verá mencionado nos artigos que leu. Está lá por uma boa razão, e isso porque, a menos que você fixe seu timestap de física em algum múltiplo integral fixo do seu timestap de renderização fixo, você simplesmente não poderá apresentar os quadros no momento certo. Você acaba apresentando-os muito cedo ou muito tarde. A única maneira de obter uma taxa de renderização fixa e ainda apresentar algo fisicamente correto é aceitar que, no momento em que o intervalo de renderização chegar, você provavelmente estará no meio do caminho entre duas de suas etapas fixas de física. Mas isso não significa que os objetos sejam modificados durante a renderização, apenas que a renderização precisa estabelecer temporariamente onde os objetos estão, para que possa renderizá-los em algum lugar entre onde eles estavam antes e onde estão após a atualização. Isso é importante - nunca mude o estado mundial para renderização, apenas as atualizações devem mudar o estado mundial.
Então, para colocá-lo em um loop de pseudocódigo, acho que você precisa de algo mais 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 isso funcione, todos os objetos que estão sendo atualizados precisam preservar o conhecimento de onde eles estavam antes e onde estão agora, para que a renderização possa usar seu conhecimento de onde o objeto está.
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);
}
}
Vamos traçar uma linha do tempo em milissegundos, dizendo que a renderização leva 3ms para ser concluída, a atualização leva 1ms, o tempo de atualização é fixado em 5ms e o tempo de renderização começa (e permanece) em 16ms [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
- Primeiro inicializamos no tempo 0 (então currentTime = 0)
- Renderizamos com uma proporção de 1,0 (100% currentTime), que atrairá o mundo no tempo 0
- Quando isso termina, o tempo real é 3 e não esperamos que o quadro termine até as 16, portanto, precisamos executar algumas atualizações
- T + 3: Atualizamos de 0 a 5 (então, currentTime = 5, previousTime = 0)
- T + 4: ainda antes do final do quadro, atualizamos de 5 para 10
- T + 5: ainda antes do final do quadro, atualizamos de 10 para 15
- T + 6: ainda antes do final do quadro, então atualizamos de 15 para 20
- T + 7: ainda antes do final do quadro, mas currentTime está além do final do quadro. Não queremos mais simular porque isso nos levaria além do tempo que queremos renderizar. Em vez disso, esperamos em silêncio pelo próximo intervalo de renderização (16)
- T + 16: é hora de renderizar novamente. previousTime é 15, currentTime é 20. Portanto, se queremos renderizar em T + 16, estamos a 1ms do caminho no intervalo de tempo de 5ms. Portanto, estamos 20% do caminho (proporção = 0,2). Quando renderizamos, desenhamos objetos 20% do caminho entre a posição anterior e a atual.
- Volte ao 3. e continue indefinidamente.
Há outra nuance aqui sobre a simulação muito adiantada, o que significa que as entradas do usuário podem ser ignoradas, mesmo que tenham ocorrido antes da renderização do quadro, mas não se preocupe com isso até ter certeza de que o loop está simulando sem problemas.