Jak faktycznie działa interpolacja, aby wygładzić ruch obiektu?

10

Zadałem kilka podobnych pytań w ciągu ostatnich 8 miesięcy bez żadnej prawdziwej radości, więc sprawię, że pytanie będzie bardziej ogólne.

Mam grę na Androida, która jest OpenGL ES 2.0. w nim mam następującą pętlę gry:

Moja pętla działa na zasadzie stałego kroku czasowego (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();   

Moja integracja działa w następujący sposób:

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

Teraz wszystko działa tak jak chciałbym. Mogę sprecyzować, że chciałbym, aby obiekt poruszał się na pewną odległość (powiedzmy na szerokości ekranu) w 2,5 sekundy, i zrobi to właśnie. Również z powodu przeskakiwania klatek, na które zezwalam w mojej pętli gry, mogę to zrobić na prawie każdym urządzeniu i zawsze zajmie to 2,5 sekundy.

Problem

Problem polega jednak na tym, że gdy ramka renderowania przeskakuje, grafika zacina się. To bardzo denerwujące. Jeśli usunę możliwość pomijania ramek, wszystko będzie gładkie, jak chcesz, ale będzie działać z różnymi prędkościami na różnych urządzeniach. Więc to nie jest opcja.

Nadal nie jestem pewien, dlaczego ramka przeskakuje, ale chciałbym zauważyć, że nie ma to nic wspólnego ze słabą wydajnością , wziąłem kod z powrotem do 1 małego duszka i nie ma logiki (oprócz logiki wymaganej do przenieś duszka), a ja wciąż pomijam klatki. I to jest na tablecie Google Nexus 10 (i jak wspomniano powyżej, muszę przeskakiwać ramki, aby utrzymać stałą prędkość na różnych urządzeniach).

Tak więc jedyną inną opcją, którą mam, jest użycie interpolacji (lub ekstrapolacji). Przeczytałem każdy artykuł, który tam jest, ale żadna naprawdę nie pomogła mi zrozumieć, jak to działa, i wszystkie moje próby implementacji zakończyły się niepowodzeniem.

Za pomocą jednej metody mogłem sprawnie poruszać się, ale było to niewykonalne, ponieważ popsuło mi to kolizję. Mogę przewidzieć ten sam problem z dowolną podobną metodą, ponieważ interpolacja jest przekazywana do (i działa w ramach) metody renderowania - w czasie renderowania. Więc jeśli pozycja kolizji koryguje (znak teraz stoi tuż obok ściany), a następnie renderujący może zmieniać swoją pozycję i wyciągnąć go na ścianie.

Więc jestem naprawdę zdezorientowany. Ludzie mówili, że nigdy nie należy zmieniać pozycji obiektu z poziomu metody renderowania, ale wszystkie przykłady online to pokazują.

Proszę więc o pchnięcie we właściwym kierunku, proszę nie zamieszczać linków do popularnych artykułów na temat pętli gry (deWitters, Fix timepep itp.), Ponieważ czytałem je wiele razy . Ja nie pytając nikogo, aby napisać kod dla mnie. Po prostu wyjaśnij proszę w prosty sposób, w jaki sposób interpolacja faktycznie działa na niektórych przykładach. Potem pójdę i spróbuję zintegrować jakieś pomysły z moim kodem i w razie potrzeby zadam bardziej szczegółowe pytania. (Jestem pewien, że jest to problem, z którym zmaga się wiele osób).

edytować

Niektóre dodatkowe informacje - zmienne używane w pętli gry.

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
źródło
A powodem głosowania jest ...................?
BungleBonce,
1
Czasami nie da się powiedzieć. Wygląda na to, że ma wszystko, co dobre pytanie powinno mieć przy próbie rozwiązania problemu. Zwięzły fragment kodu, wyjaśnienia tego, czego próbowałeś, próby badań oraz jasne wyjaśnienie, na czym polega Twój problem i co musisz wiedzieć.
Jesse Dorsey
Nie byłem twoim głosem negatywnym, ale proszę wyjaśnij jedną część. Mówisz, że grafika zacina się, gdy ramka jest pomijana. To wydaje się oczywistym stwierdzeniem (ramka jest pominięta, wygląda na to, że ramka została pominięta). Czy możesz lepiej wyjaśnić pominięcie? Czy dzieje się coś dziwniejszego? Jeśli nie, może to stanowić problem nierozwiązywalny, ponieważ nie można uzyskać płynnego ruchu, jeśli liczba klatek na sekundę spadnie.
Seth Battin
Dzięki, Noctrine, to naprawdę denerwuje mnie, gdy ludzie głosują bez komentarza. @SethBattin, przepraszam, tak, oczywiście, masz rację, przeskakiwanie klatek powoduje szarpanie, jednak jakaś interpolacja powinna rozwiązać ten problem, jak mówiłem powyżej, miałem pewien (ale ograniczony) sukces. Jeśli się mylę, wydaje mi się, że pytanie brzmi: jak mogę sprawnie działać z tą samą prędkością na różnych urządzeniach?
BungleBonce
4
Ostrożnie ponownie przeczytaj te dokumenty. W rzeczywistości nie modyfikują położenia obiektu w metodzie renderowania. Zmieniają jedynie pozorną lokalizację metody na podstawie jej ostatniej pozycji i jej bieżącej pozycji na podstawie czasu, który upłynął.
AttackingHobo

Odpowiedzi:

5

Są dwie rzeczy niezbędne do uzyskania płynnego ruchu, po pierwsze, oczywiste jest, że to, co renderujesz, musi odpowiadać oczekiwanemu stanowi w momencie, w którym ramka jest prezentowana użytkownikowi, po drugie, musisz przedstawić klatki użytkownikowi w stosunkowo ustalonym odstępie czasu. Prezentowanie klatki w T + 10ms, potem kolejna w T + 30ms, a następnie w T + 40ms, wydaje się użytkownikowi, że ocenia, nawet jeśli to, co faktycznie pokazano dla tych czasów, jest zgodne z symulacją.

Wydaje się, że w głównej pętli brakuje mechanizmu bramkowania, aby zapewnić, że renderujesz tylko w regularnych odstępach czasu. Czasami możesz zrobić 3 aktualizacje między renderami, czasem możesz zrobić 4. Zasadniczo twoja pętla będzie renderować tak często, jak to możliwe, gdy tylko zasymulujesz wystarczająco dużo czasu, aby przesunąć stan symulacji przed bieżącym czasem, będziesz następnie render ten stan. Ale każda zmienność czasu potrzebnego do aktualizacji lub renderowania oraz odstępy między ramkami również będą się różnić. Masz ustalony czas symulacji, ale zmienny czas renderowania.

Prawdopodobnie potrzebujesz poczekać tuż przed renderowaniem, co gwarantuje, że rendering zaczniesz tylko na początku interwału renderowania. Idealnie byłoby, gdyby był adaptacyjny: jeśli zbyt długo trwało aktualizowanie / renderowanie, a początek interwału już minął, należy renderować natychmiast, ale także zwiększać długość interwału, dopóki nie będzie można konsekwentnie renderować i aktualizować, a następnie przejść do następne renderowanie przed zakończeniem interwału. Jeśli masz dużo czasu, możesz powoli zmniejszyć interwał (tj. Zwiększyć liczbę klatek na sekundę), aby ponownie renderować szybciej.

Ale tutaj jest kicker, jeśli nie wyrenderujesz ramki natychmiast po wykryciu, że stan symulacji został zaktualizowany do „teraz”, to wprowadzisz tymczasowe aliasing. Ramka prezentowana użytkownikowi jest prezentowana w nieco niewłaściwym czasie, a to samo w sobie będzie się jąkać.

Jest to powód „częściowego pomiaru czasu”, o którym wspominasz w artykułach, które przeczytałeś. Jest tam z dobrego powodu, a to dlatego, że dopóki nie ustawisz timepsu fizyki na jakąś stałą całkowitą wielokrotność twojego timeptu renderowania, po prostu nie możesz przedstawić ramek we właściwym czasie. W końcu albo prezentujesz je za wcześnie, albo za późno. Jedynym sposobem na uzyskanie stałej szybkości renderowania i nadal prezentowanie czegoś, co jest fizycznie poprawne, jest zaakceptowanie tego, że w momencie, gdy nadejdzie interwał renderowania, najprawdopodobniej będziesz w połowie drogi między dwoma ustalonymi czasami fizyki. Ale to nie znaczy, że obiekty są modyfikowane podczas renderowania, wystarczy, że rendering musi tymczasowo ustalić, gdzie znajdują się obiekty, aby mógł je renderować gdzieś pomiędzy tym, gdzie były przed i gdzie są po aktualizacji. To ważne - nigdy nie zmieniaj stanu świata do renderowania, tylko aktualizacje powinny zmieniać stan świata.

Aby umieścić go w pętli pseudokodu, myślę, że potrzebujesz czegoś więcej:

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);
}

Aby to zadziałało, wszystkie aktualizowane obiekty muszą zachować wiedzę o tym, gdzie były wcześniej i gdzie są teraz, aby rendering mógł wykorzystać swoją wiedzę o tym, gdzie jest obiekt.

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);
    }
}

Nakreślmy oś czasu w milisekundach, mówiąc, że renderowanie zajmuje 3 ms, aktualizacja zajmuje 1 ms, krok aktualizacji jest ustalony na 5 ms, a czas renderowania rozpoczyna się (i pozostaje) na 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
  1. Najpierw inicjalizujemy w czasie 0 (więc currentTime = 0)
  2. Renderujemy z proporcją 1,0 (100% currentTime), która narysuje świat w czasie 0
  3. Kiedy to się skończy, rzeczywisty czas to 3 i nie spodziewamy się, że ramka skończy się do 16, więc musimy uruchomić kilka aktualizacji
  4. T + 3: Aktualizujemy od 0 do 5 (więc później currentTime = 5, previousTime = 0)
  5. T + 4: wciąż przed końcem ramki, więc aktualizujemy z 5 do 10
  6. T + 5: jeszcze przed końcem ramki, więc aktualizujemy z 10 do 15
  7. T + 6: wciąż przed końcem ramki, więc aktualizujemy z 15 do 20
  8. T + 7: wciąż przed końcem ramki, ale currentTime jest tuż poza końcem ramki. Nie chcemy dalej symulować, ponieważ spowodowałoby to przekroczenie terminu renderowania. Zamiast tego cicho czekamy na następny interwał renderowania (16)
  9. T + 16: Czas ponownie renderować. previousTime to 15, currentTime to 20. Więc jeśli chcemy renderować w T + 16, to jesteśmy 1ms drogi przez 5ms długi czas. Czyli jesteśmy 20% drogi przez ramkę (proporcja = 0,2). Podczas renderowania rysujemy obiekty w odległości 20% między ich poprzednią pozycją a obecną pozycją.
  10. Powróć do 3. i kontynuuj w nieskończoność.

Jest jeszcze jeden niuans dotyczący zbytniej symulacji z wyprzedzeniem, co oznacza, że ​​dane wejściowe użytkownika mogą zostać zignorowane, nawet jeśli zdarzyły się przed faktycznym renderowaniem ramki, ale nie przejmuj się tym, dopóki nie upewnisz się, że pętla symuluje płynnie.

MrCranky
źródło
Uwaga: pseudokod jest słaby na dwa sposoby. Po pierwsze, nie wychwytuje przypadku spirali śmierci (aktualizacja trwa dłużej niż fixedTimeStep, co oznacza, że ​​symulacja jest jeszcze bardziej opóźniona, w efekcie nieskończona pętla), po drugie renderInterval nigdy się nie skraca. W praktyce chcesz natychmiast zwiększyć wartość renderInterval, ale z czasem skracaj ją stopniowo, najlepiej jak potrafisz, w granicach tolerancji rzeczywistego czasu klatki. W przeciwnym razie jedna zła / długa aktualizacja na zawsze zasypie Cię niską liczbą klatek na sekundę.
MrCranky
Dzięki za to @MrCranky, od dawna walczyłem o to, jak „ograniczyć” renderowanie w mojej pętli! Po prostu nie mogłem wymyślić, jak to zrobić i zastanawiałem się, czy to może być jeden z problemów. Przeczytam to dokładnie i dam ci sugestie, przekażę raport!
Jeszcze
Dzięki @MrCranky, OK, przeczytałem i ponownie przeczytałem twoją odpowiedź, ale nie rozumiem jej :-( Próbowałem ją zaimplementować, ale po prostu dał mi pusty ekran. Naprawdę mam z tym problem. Poprzednia ramka i bieżąca ramka Zakładam odnosi się do poprzedniej i bieżącej pozycji moich ruchomych obiektów? A co z linią „currentFrame = Update ();” - Nie dostaję tej linii, czy to oznacza wywołanie update (); ponieważ nie widzę, gdzie inaczej nazywam update? A może to po prostu ustawianie currentFrame (pozycji) na nową wartość?
Jeszcze
Tak, skutecznie. Powodem, dla którego wstawiłem previousFrame i currentFrame jako wartości zwracane z Update i InitialiseWorldState, jest to, że aby umożliwić renderowanie w celu narysowania świata, ponieważ jest on w połowie drogi między dwoma stałymi krokami aktualizacji, musisz mieć nie tylko bieżącą pozycję każdego obiekt, który chcesz narysować, ale także ich poprzednie pozycje. Możesz mieć każdy obiekt wewnętrznie zapisujący obie wartości, co staje się niewygodne.
MrCranky
Ale jest również możliwe (ale znacznie trudniejsze) zaprojektowanie rzeczy tak, aby wszystkie informacje o stanie potrzebne do przedstawienia aktualnego stanu świata w czasie T były przechowywane pod jednym obiektem. Pod względem koncepcyjnym jest to o wiele czystsze, gdy wyjaśniamy, jakie informacje są w systemie, ponieważ można traktować stan ramki jako coś wygenerowanego przez krok aktualizacji, a utrzymanie poprzedniej ramki to po prostu zachowanie jednego z tych obiektów stanu ramki. Jednak mógłbym przepisać odpowiedź na nieco bardziej, jakbyś prawdopodobnie ją zaimplementował.
MrCranky
3

To, co wszyscy ci mówią, jest poprawne. Nigdy nie aktualizuj pozycji symulacji duszka w logice renderowania.

Pomyśl o tym w ten sposób, twój duszek ma 2 pozycje; gdzie symulacja mówi, że jest od ostatniej aktualizacji symulacji, i gdzie jest renderowany duszek. Są to dwie zupełnie różne współrzędne.

Duszek jest renderowany w jego ekstrapolowanej pozycji. Pozycja ekstrapolowana jest obliczana dla każdej ramki renderowania, używana do renderowania duszka, a następnie wyrzucana. To wszystko.

Poza tym wydaje się, że dobrze rozumiesz. Mam nadzieję że to pomoże.

William Morrison
źródło
Znakomity @WilliamMorrison - dziękuję za potwierdzenie tego, nigdy tak naprawdę nie byłem w 100% pewien, że tak jest, teraz myślę, że jestem na dobrej drodze do tego, aby działał do pewnego stopnia - na zdrowie!
BungleBonce,
Po prostu ciekawy @WilliamMorrison, używając tych współrzędnych odrzucenia, w jaki sposób można złagodzić problem, że duszki są rysowane „osadzone” lub „tuż nad” innymi obiektami - oczywistym przykładem są stałe obiekty w grze 2D. Czy musiałbyś również uruchomić swój kod kolizyjny w czasie renderowania?
BungleBonce,
W moich grach tak właśnie robię. Proszę bądź lepszy ode mnie, nie rób tego, to nie jest najlepsze rozwiązanie. Komplikuje kod renderowania z logiką, której nie powinien używać, i marnuje procesor na zbędne wykrywanie kolizji. Lepiej interpolować między pozycją od drugiej do ostatniej a pozycją bieżącą. To rozwiązuje problem, ponieważ nie ekstrapolujesz do złej pozycji, ale komplikuje wszystko, gdy robisz krok do przodu za symulacją. Chciałbym usłyszeć twoją opinię, jakie podejście wybierasz i swoje doświadczenia.
William Morrison,
Tak, to trudny problem do rozwiązania. Zadałem osobne pytanie dotyczące tego tutaj gamedev.stackexchange.com/questions/83230/…, jeśli chcesz mieć na to oko lub coś wnieść. A teraz, co zasugerowałeś w swoim komentarzu, czy ja już tego nie robię? (Interpolacja między poprzednią i bieżącą ramką)?
BungleBonce,
Nie do końca. W tej chwili ekstrapolujesz. Bierzesz najbardziej aktualne dane z symulacji i ekstrapolujesz ich wygląd po ułamkowych odcinkach czasu. Sugeruję interpolację między ostatnią pozycją symulacji a bieżącą pozycją symulacji za pomocą ułamkowych kroków czasowych renderowania. Renderowanie będzie poza symulacją o 1 pomiar czasu. Dzięki temu nigdy nie renderujesz obiektu w stanie, w którym symulacja się nie potwierdziła (tzn. Pocisk nie pojawi się w ścianie, dopóki symulacja się nie powiedzie.)
William Morrison,