Como a interpolação realmente funciona para suavizar o movimento de um objeto?

10

Fiz algumas perguntas semelhantes nos últimos 8 meses sem muita alegria, por isso vou tornar a pergunta mais geral.

Eu tenho um jogo Android que é o OpenGL ES 2.0. dentro dele eu tenho o seguinte Game Loop:

Meu loop funciona em um princípio de etapa de tempo fixo (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();   

Minha integração funciona assim:

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

Agora, tudo funciona da maneira que eu gostaria. Posso especificar que eu gostaria que um objeto se movesse por uma certa distância (digamos, a largura da tela) em 2,5 segundos e isso será feito. Também devido ao salto de quadros que eu permito no loop do jogo, eu posso fazer isso em praticamente qualquer dispositivo e sempre levará 2,5 segundos.

Problema

No entanto, o problema é que, quando um quadro de renderização é ignorado, o gráfico é interrompido. É extremamente irritante. Se eu remover a capacidade de pular quadros, tudo ficará tranquilo como você gosta, mas será executado em velocidades diferentes em dispositivos diferentes. Portanto, não é uma opção.

Ainda não sei ao certo por que o quadro pula, mas gostaria de salientar que isso não tem nada a ver com desempenho insatisfatório . Retornei o código a 1 sprite minúsculo e sem lógica (além da lógica necessária para mover o sprite) e ainda recebo quadros ignorados. E isso está em um tablet Google Nexus 10 (e, como mencionado acima, eu preciso de pulos de quadros para manter a velocidade consistente em todos os dispositivos).

Portanto, a única outra opção que tenho é usar a interpolação (ou extrapolação), li todos os artigos existentes, mas nenhum realmente me ajudou a entender como funciona e todas as minhas tentativas de implementação falharam.

Usando um método, eu consegui fazer as coisas funcionarem sem problemas, mas era impraticável porque atrapalhava minha colisão. Posso prever o mesmo problema com qualquer método semelhante, porque a interpolação é passada para (e atuada dentro) o método de renderização - no momento da renderização. Portanto, se a Colisão corrigir a posição (o personagem está agora próximo à parede), o renderizador pode alterar sua posição e desenhá-lo na parede.

Então, eu estou realmente confuso. As pessoas disseram que você nunca deve alterar a posição de um objeto de dentro do método de renderização, mas todos os exemplos online mostram isso.

Então, eu estou pedindo um empurrão na direção certa, por favor, não vincule aos artigos populares sobre loop de jogos (deWitters, Corrija seu timestep, etc.), já que li várias vezes . Estou não pedir a ninguém para escrever meu código para mim. Apenas explique por favor em termos simples como a Interpolação realmente funciona com alguns exemplos. Depois, tentarei integrar quaisquer idéias ao meu código e farei perguntas mais específicas, se necessário - mais adiante. (Tenho certeza de que esse é um problema com o qual muitas pessoas lutam).

editar

Algumas informações adicionais - variáveis ​​usadas no loop do jogo.

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
fonte
E a razão do voto negativo é ...................?
BungleBonce
1
Impossível dizer algumas vezes. Parece ter tudo o que uma boa pergunta deve ter ao tentar resolver um problema. Trecho de código conciso, explicações sobre o que você tentou, tentativas de pesquisa e explicando claramente qual é o seu problema e o que você precisa saber.
Jesse Dorsey
Não fui seu voto negativo, mas por favor esclareça uma parte. Você diz que os gráficos gaguejam quando um quadro é pulado. Parece uma afirmação óbvia (um quadro está ausente, parece que um quadro está ausente). Então, você pode explicar melhor o salto? Acontece algo mais estranho? Caso contrário, isso pode ser um problema insolúvel, porque você não pode obter movimentos suaves se a taxa de quadros cair.
precisa saber é o seguinte
Obrigado, Noctrine, isso realmente me irrita quando as pessoas votam sem deixar uma explicação. @SethBattin, desculpe, sim, é claro, você está certo, o salto de quadros está causando estremecimento, no entanto, uma interpolação de algum tipo deve resolver isso, como eu disse acima, tive algum (mas limitado) sucesso. Se eu estiver errado, acho que a pergunta seria: como fazê-lo funcionar sem problemas na mesma velocidade em vários dispositivos?
BungleBonce
4
Releia cuidadosamente esses documentos. Na verdade, eles não modificam a localização do objeto no método de renderização. Eles apenas modificam a localização aparente do método com base em sua última posição e sua posição atual com base em quanto tempo se passou.
AttackingHobo

Respostas:

5

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
  1. Primeiro inicializamos no tempo 0 (então currentTime = 0)
  2. Renderizamos com uma proporção de 1,0 (100% currentTime), que atrairá o mundo no tempo 0
  3. Quando isso termina, o tempo real é 3 e não esperamos que o quadro termine até as 16, portanto, precisamos executar algumas atualizações
  4. T + 3: Atualizamos de 0 a 5 (então, currentTime = 5, previousTime = 0)
  5. T + 4: ainda antes do final do quadro, atualizamos de 5 para 10
  6. T + 5: ainda antes do final do quadro, atualizamos de 10 para 15
  7. T + 6: ainda antes do final do quadro, então atualizamos de 15 para 20
  8. 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)
  9. 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.
  10. 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.

MrCranky
fonte
Nota: o pseudocódigo é fraco de duas maneiras. Em primeiro lugar, ele não pega o caso da espiral da morte (leva mais tempo que o FixedTimeStep para atualizar, o que significa que a simulação fica ainda mais para trás, efetivamente um loop infinito); em segundo lugar, o renderInterval nunca é reduzido novamente. Na prática, você deseja aumentar o renderInterval imediatamente, mas, com o tempo, reduza-o gradualmente da melhor maneira possível, dentro de alguma tolerância com o tempo real do quadro. Caso contrário, uma atualização ruim / longa o deixará com uma taxa de quadros baixa para sempre.
MrCranky
Obrigado por este @MrCranky, de fato, tenho lutado há anos sobre como 'limitar' a renderização no meu loop! Só não conseguia descobrir como fazê-lo e me perguntei se esse poderia ser um dos problemas. Terei uma leitura adequada e tentarei suas sugestões, apresentarei um relatório! Mais uma vez obrigado :-)
BungleBonce
Obrigado @MrCranky, OK, eu li e reli sua resposta, mas não consigo entendê-la :-( Tentei implementá-la, mas ela só me deu uma tela em branco. Realmente lutando com isso. PreviousFrame e currentFrame Presumo se relaciona com as posições anteriores e atuais dos meus objetos em movimento? Além disso, e a linha "currentFrame = Update ();" - eu não entendo essa linha, isso significa chamar update (); pois não consigo ver onde mais eu estou chamando update ou significa apenas para definir currentFrame (posição) ao seu novo valor Obrigado novamente por sua ajuda !!?
BungleBonce
Sim, efetivamente. A razão pela qual eu coloquei previousFrame e currentFrame como valores de retorno de Update e InitialiseWorldState é porque, para permitir que a renderização desenhe o mundo, pois é meio caminho entre duas etapas fixas de atualização, você precisa não apenas da posição atual de cada objeto que você deseja desenhar, mas também suas posições anteriores. Você pode fazer com que cada objeto salve os dois valores internamente, o que fica pesado.
MrCranky
Mas também é possível (mas muito mais difícil) arquitetar as coisas para que todas as informações de estado necessárias para representar o estado atual do mundo no momento T sejam mantidas sob um único objeto. Conceitualmente, é muito mais claro ao explicar quais informações existem no sistema, pois você pode tratar o estado do quadro como algo produzido por uma etapa de atualização, e manter o quadro anterior por perto é apenas manter um desses objetos de estado do quadro. No entanto, posso reescrever a resposta para ser um pouco mais parecida com a que você provavelmente implementaria.
MrCranky
3

O que todos estão dizendo a você está correto. Nunca atualize a posição de simulação do seu sprite em sua lógica de renderização.

Pense assim, seu sprite tem 2 posições; onde a simulação diz que ele está na última atualização da simulação e onde o sprite é renderizado. São duas coordenadas completamente diferentes.

O sprite é renderizado em sua posição extrapolada. A posição extrapolada é calculada em cada quadro de renderização, usado para renderizar o sprite e depois jogado fora. É tudo o que há para isso.

Fora isso, você parece ter um bom entendimento. Espero que isto ajude.

William Morrison
fonte
Excelente @WilliamMorrison - obrigado por confirmar isso, eu nunca tive 100% de certeza de que era esse o caso, agora acho que estou no meu caminho para fazer isso funcionar até certo ponto - felicidades!
BungleBonce
Apenas o curioso @WilliamMorrison, usando essas coordenadas descartáveis, como mitigar o problema de sprites serem "incorporados" ou "logo acima" de outros objetos - o exemplo óbvio, sendo objetos sólidos em um jogo 2D. Você também precisaria executar seu código de colisão no momento da renderização?
BungleBonce
Nos meus jogos sim, é isso que eu faço. Por favor, seja melhor que eu, não faça isso, não é a melhor solução. Isso complica o código de renderização com a lógica que não deveria estar sendo usada e desperdiçará o processador na detecção de colisão redundante. Seria melhor interpolar entre a penúltima e a atual posição. Isso resolve o problema, pois você não está extrapolando para uma posição ruim, mas complica as coisas ao dar um passo atrás da simulação. Eu adoraria ouvir sua opinião, qual abordagem você adota e suas experiências.
William Morrison
Sim, é um problema complicado de resolver. Fiz uma pergunta separada sobre isso aqui gamedev.stackexchange.com/questions/83230/… se você quiser ficar de olho ou contribuir com algo. Agora, o que você sugeriu em seu comentário, eu não estou fazendo isso já? (Interpolar entre o quadro anterior e o atual)?
BungleBonce
Nem tanto. Você está realmente extrapolando agora. Você pega os dados mais atuais da simulação e extrapola a aparência desses dados após timestaps fracionários. Estou sugerindo que você interpole entre a última posição da simulação e a posição atual da simulação por timestados fracionários para renderização. A renderização ficará por trás da simulação em 1 timestep. Isso garante que você nunca vai tornar um objeto em um estado a simulação não validar (ou seja Um projétil não aparecerá em uma parede a menos que a simulação falha..)
William Morrison