¿Cómo funciona realmente la interpolación para suavizar el movimiento de un objeto?

10

He hecho algunas preguntas similares en los últimos 8 meses más o menos sin verdadera alegría, así que haré la pregunta más general.

Tengo un juego de Android que es OpenGL ES 2.0. dentro tengo el siguiente Game Loop:

Mi ciclo funciona en un principio de paso de tiempo fijo (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();   

Mi integración funciona así:

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

Ahora, todo funciona más o menos como me gustaría. Puedo especificar que me gustaría que un objeto se mueva a través de una cierta distancia (por ejemplo, el ancho de la pantalla) en 2.5 segundos y lo hará. Además, debido al salto de fotogramas que permito en mi ciclo de juego, puedo hacer esto en casi cualquier dispositivo y siempre tomará 2.5 segundos.

Problema

Sin embargo, el problema es que cuando se omite un marco de representación, el gráfico tartamudea. Es extremadamente molesto. Si elimino la capacidad de omitir cuadros, entonces todo es suave como lo desee, pero se ejecutará a diferentes velocidades en diferentes dispositivos. Entonces no es una opción.

Todavía no estoy seguro de por qué se salta el marco, pero me gustaría señalar que esto no tiene nada que ver con el bajo rendimiento , he llevado el código de vuelta a 1 pequeño sprite y sin lógica (aparte de la lógica requerida para muevo el sprite) y todavía me saltan cuadros. Y esto está en una tableta Google Nexus 10 (y como se mencionó anteriormente, necesito omitir cuadros para mantener la velocidad constante en todos los dispositivos).

Entonces, la única otra opción que tengo es usar la interpolación (o extrapolación), he leído todos los artículos que existen, pero ninguno realmente me ha ayudado a comprender cómo funciona y todas mis implementaciones intentadas han fallado.

Utilizando un método, pude hacer que las cosas se movieran sin problemas, pero no fue posible porque arruinó mi colisión. Puedo prever el mismo problema con cualquier método similar porque la interpolación se pasa (y actúa dentro) al método de representación, en el momento de la representación. Entonces, si Collision corrige la posición (el personaje ahora está parado justo al lado del muro), entonces el renderizador puede alterar su posición y dibujarlo en el muro.

Entonces estoy realmente confundido. La gente ha dicho que nunca deberías alterar la posición de un objeto desde el método de renderizado, pero todos los ejemplos en línea lo muestran.

Por lo tanto, estoy pidiendo un empujón en la dirección correcta, por favor no se vincule a los artículos populares del bucle del juego (deWitters, Fix your timestep, etc.) ya que lo he leído varias veces . No le pido a nadie que escriba mi código por mí. Simplemente explique por favor en términos simples cómo la interpolación realmente funciona con algunos ejemplos. Luego iré e intentaré integrar cualquier idea en mi código y haré preguntas más específicas si es necesario más adelante. (Estoy seguro de que este es un problema con el que muchas personas luchan).

editar

Alguna información adicional: variables utilizadas en el bucle del juego.

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
fuente
¿Y la razón del voto negativo es ...................?
BungleBonce
1
Imposible decir a veces. Esto parece tener todo lo que una buena pregunta debería tener al tratar de resolver un problema. Fragmento de código conciso, explicaciones de lo que ha intentado, intentos de investigación y una explicación clara de cuál es su problema y lo que necesita saber.
Jesse Dorsey
No fui tu voto negativo, pero por favor aclara una parte. Dices que los gráficos tartamudean cuando se omite un marco. Eso parece una declaración obvia (se pierde un marco, parece que se pierde un marco). Entonces, ¿puedes explicar mejor la omisión? ¿Pasa algo más extraño? De lo contrario, este podría ser un problema sin solución, ya que no puede obtener un movimiento suave si la velocidad de fotogramas disminuye.
Seth Battin
Gracias, Noctrine, realmente me molesta cuando la gente rechaza sin dejar una explicación. @SethBattin, lo siento, sí, por supuesto, tienes razón, el salto de fotograma está causando la sacudida, sin embargo, la interpolación de algún tipo debería solucionar esto, como digo anteriormente, he tenido un éxito (pero limitado). Si me equivoco, entonces supongo que la pregunta sería, ¿cómo puedo hacer que funcione sin problemas a la misma velocidad en varios dispositivos?
BungleBonce
44
Vuelva a leer cuidadosamente esos documentos. En realidad, no modifican la ubicación del objeto en el método de representación. Solo modifican la ubicación aparente del método en función de su última posición y su posición actual en función de cuánto tiempo ha pasado.
AttackingHobo

Respuestas:

5

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
  1. Primero iniciamos en el tiempo 0 (entonces currentTime = 0)
  2. Representamos con una proporción de 1.0 (100% currentTime), que dibujará el mundo en el momento 0
  3. 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
  4. T + 3: Actualizamos de 0 a 5 (por lo tanto, currentTime = 5, previousTime = 0)
  5. T + 4: aún antes del final del marco, por lo que actualizamos de 5 a 10
  6. T + 5: aún antes del final del marco, por lo que actualizamos de 10 a 15
  7. T + 6: todavía antes del final del marco, por lo que actualizamos de 15 a 20
  8. 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)
  9. 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.
  10. 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.

MrCranky
fuente
NB: el pseudocódigo es débil de dos maneras. En primer lugar, no capta el caso de la espiral de la muerte (lleva más tiempo que el fijoTimeStep para actualizar, lo que significa que la simulación se queda aún más atrás, efectivamente un bucle infinito), en segundo lugar, el renderInterval nunca se acorta de nuevo. En la práctica, desea aumentar el renderInterval de inmediato, pero luego, con el tiempo, acortarlo gradualmente lo mejor que pueda, dentro de cierta tolerancia del tiempo de cuadro real. De lo contrario, una actualización mala / larga lo mantendrá con una baja tasa de cuadros por siempre.
MrCranky
¡Gracias por este @MrCranky, de hecho, he estado luchando durante años sobre cómo 'limitar' la representación en mi bucle! Simplemente no podía entender cómo hacerlo y me preguntaba si ese podría ser uno de los problemas. Leeré bien esto y probaré sus sugerencias. ¡Informaré! Gracias de nuevo :-)
BungleBonce
Gracias @MrCranky, OK, he leído y releído tu respuesta pero no puedo entenderla :-( Intenté implementarla pero solo me dio una pantalla en blanco. Realmente estoy luchando con esto. AnteriorFrame y currentFrame Asumo se relaciona con las posiciones anteriores y actuales de mis objetos en movimiento? Además, ¿qué pasa con la línea "currentFrame = Update ();" - No obtengo esta línea, significa la actualización de la llamada (); como no puedo ver dónde de lo contrario, estoy llamando a la actualización o ¿simplemente significa establecer currentFrame (posición) en su nuevo valor? ¡Gracias de nuevo por su ayuda!
BungleBonce
Si, efectivamente. La razón por la que puse en PreviousFrame y currentFrame como valores de retorno de Update e InitialiseWorldState es porque para permitir que el renderizado dibuje el mundo ya que está a medio camino entre dos pasos de actualización fijos, debe tener no solo la posición actual de cada objeto que desea dibujar, pero también sus posiciones anteriores. Puede hacer que cada objeto guarde ambos valores internamente, lo que se vuelve difícil de manejar.
MrCranky
Pero también es posible (pero mucho más difícil) diseñar cosas para que toda la información de estado necesaria para representar el estado actual del mundo en el momento T se mantenga bajo un solo objeto. Conceptualmente, eso es mucho más claro al explicar qué información hay en el sistema, ya que puede tratar el estado del marco como algo producido por un paso de actualización, y mantener el marco anterior alrededor consiste en retener uno más de esos objetos de estado de marco. Sin embargo, podría reescribir la respuesta para que sea un poco más como si realmente la implementaras.
MrCranky
3

Lo que todos te han estado diciendo es correcto. Nunca actualice la posición de simulación de su sprite en su lógica de renderizado.

Piénsalo así, tu sprite tiene 2 posiciones; donde la simulación dice que está en la última actualización de simulación, y donde se representa el sprite. Son dos coordenadas completamente diferentes.

El sprite se representa en su posición extrapolada. La posición extrapolada se calcula en cada cuadro de renderizado, se usa para renderizar el sprite y luego se tira. Eso es todo al respecto.

Aparte de eso, pareces tener una buena comprensión. Espero que esto ayude.

William Morrison
fuente
Excelente @WilliamMorrison: gracias por confirmar esto, nunca estuve realmente 100% seguro de que este fuera el caso, ahora creo que estoy en camino de hacer que esto funcione hasta cierto punto, ¡salud!
BungleBonce
Simplemente curioso @WilliamMorrison, usando estas coordenadas desechables, ¿cómo se mitigaría el problema de que los sprites se dibujen 'incrustados' o 'justo encima' de otros objetos? El ejemplo obvio, ser objetos sólidos en un juego 2D. ¿Tendrías que ejecutar tu código de colisión también en el momento de renderizado?
BungleBonce
En mis juegos sí, eso es lo que hago. Sé mejor que yo, no hagas eso, no es la mejor solución. Complica el código de renderizado con la lógica que no debería usar, y desperdiciará la CPU en la detección de colisión redundante. Sería mejor interpolar entre la penúltima posición y la posición actual. Esto resuelve el problema ya que no está extrapolando a una mala posición, pero complica las cosas ya que está dando un paso detrás de la simulación. Me encantaría conocer tu opinión, el enfoque que adoptas y tus experiencias.
William Morrison
Sí, es un problema complicado de resolver. He hecho una pregunta por separado sobre esto aquí gamedev.stackexchange.com/questions/83230/… si quieres vigilarlo o contribuir con algo. Ahora, lo que sugirió en su comentario, ¿no estoy haciendo esto ya? (¿Interpolando entre el cuadro anterior y el actual)?
BungleBonce
No exactamente. En realidad estás extrapolando en este momento. Se toman los datos más actuales de la simulación y se extrapola el aspecto de esos datos después de pasos de tiempo fraccionarios. Te sugiero que interpoles entre la última posición de simulación y la posición de simulación actual en intervalos de tiempo fraccionados para renderizar. La representación estará detrás de la simulación en 1 paso. Esto asegura que nunca representará un objeto en un estado que la simulación no haya validado (es decir, un proyectil no aparecerá en una pared a menos que la simulación falle).
William Morrison