Dowiedz się więcej o renderowaniu w pętlach gry

Bardzo często używana pętla gry wygląda tak:

while (playing) {
    advance state by one frame
    render the new frame
    sleep until it’s time to do the next frame
}

Jest z tym kilka problemów, a najważniejszym jest to, że gra może zdefiniować, czym jest „ramka”. Różne wyświetlacze będą odświeżać się w różnym tempie, a częstotliwość ta może się z czasem zmieniać. Jeśli generujesz klatki szybciej, niż będzie widać na ekranie, musisz co jakiś czas pomijać jedną z nich. Jeśli będziesz generować je zbyt wolno, SurfaceFlinger okresowo nie znajdzie nowego bufora do pobrania i ponownie wyświetli poprzednią klatkę. Obie te sytuacje mogą powodować zauważalne błędy.

Wystarczy, że dostosujesz liczbę klatek na ekranie i przejdziesz na wyższy stan gry zgodnie z tym, ile czasu upłynęło od poprzedniej klatki. Możesz to zrobić na kilka sposobów:

  • Użyj biblioteki Android Frame Pacing (zalecane)
  • Wypełniaj BufferQueue i korzystaj z wyciążenia „wymiany buforów”
  • Użyj Choreografa (API na poziomie 16 lub wyższym)

Biblioteka Android Frame Pacing

Więcej informacji o korzystaniu z tej biblioteki znajdziesz w artykule na temat odpowiedniego tempa klatek.

Upychanie kolejki

Wdrożenie tej funkcji jest bardzo łatwe – po prostu jak najszybciej wymieniaj bufory. We wczesnych wersjach Androida może to skutkować sankcjami, w ramach których aplikacja SurfaceView#lockCanvas() umieszczałaby się w stanie snu na 100 ms. Teraz czas działania jest ustalany przez BufferQueue, a BufferQueue jest opróżniana tak szybko, jak jest to możliwe.

Przykładem może być raport Android Breakout. Wykorzystuje on GLSurfaceView, który działa w pętli i wywołuje wywołanie zwrotne aplikacji onDrawFrame(), a następnie zamienia bufor. Jeśli BufferQueue jest pełna, wywołanie eglSwapBuffers() będzie czekać na zwolnienie bufora. Bufory stają się dostępne po udostępnieniu ich przez SurfaceFlinger, co następuje po zakupie nowego do wyświetlania. Ponieważ dzieje się to w VSYNC, czas pętli rysowania jest zgodny z częstotliwością odświeżania. Najczęściej.

Z tym podejściem występuje kilka problemów. Po pierwsze, aplikacja jest powiązana z aktywnością SurfaceFlinger, która zajmuje różną ilość czasu w zależności od tego, ile zostało do wykonania, i czy walczy z czasem pracy procesora przez inne procesy. Stan gry zmienia się zgodnie z czasem między zamianami bufora, więc animacja nie jest aktualizowana ze stałą częstotliwością. Przy 60 kl./s, przy uśrednionych z czasem niespójnościach prawdopodobnie nie da się zauważyć skoków.

Po drugie kilka pierwszych zamiany bufora nastąpi bardzo szybko, ponieważ BufferQueue nie jest jeszcze pełna. Czas obliczony między klatkami będzie bliski 0, więc gra wygeneruje kilka klatek, w których nic się nie dzieje. W grach takich jak Breakout, które aktualizują ekran przy każdym odświeżeniu, kolejka jest zawsze pełna, chyba że gra rozpoczyna się (lub jest wznowiona) po raz pierwszy, więc tego efektu nie da się zauważyć. W grze, która od czasu do czasu wstrzymuje animację, a potem powraca do trybu „jak najszybciej”, mogą występować dziwne problemy.

Choreographer

Choreograf umożliwia ustawienie wywołania zwrotnego, które zostanie uruchomione przy kolejnym VSYNC. Rzeczywisty czas VSYNC jest przekazywany jako argument. Dzięki temu nawet jeśli aplikacja nie wybudzi się od razu, nadal masz dokładny obraz, kiedy rozpoczął się okres odświeżania wyświetlacza. Użycie tej wartości zamiast bieżącej godziny pozwala uzyskać spójne źródło czasu dla logiki aktualizacji stanu gry.

Niestety fakt, że po każdym oddzwonieniu otrzymasz odpowiedź, nie gwarantuje, że wywołanie zostanie wykonane w odpowiednim czasie ani że będziesz w stanie szybko się z nim zapoznać. Aplikacja musi wykryć sytuacje, w których jest w tyle, i ręcznie usunąć klatki.

Przykładem może być aktywność „Record GL” w Grafika. Na niektórych urządzeniach (np. Nexusach 4 i 5) aktywność zacznie spadać klatki, gdy tylko siedzisz i patrzysz. Renderowanie GL jest proste, ale czasami elementy widoku zostają przerysowane, a przekazywanie pomiaru/układu może zająć bardzo dużo czasu, jeśli urządzenie zostanie w trybie zmniejszonej mocy. (Zgodnie z symulacją systemu Android w przypadku Androida 4.4 czas ten wynosi 28 ms, a nie 6 ms. Jeśli przeciągniesz palcem po ekranie, może to oznaczać, że wchodzisz w interakcję z aktywnością. Czas zegara jest wysoki i nigdy nie tracisz klatki).

Najprostszą poprawką było usunięcie klatki w wywołaniu zwrotnym Choreografa, jeśli bieżący czas przypada ponad N mili po czasie VSYNC. Najlepiej, aby wartość N była określana na podstawie zaobserwowanych wcześniej przedziałów VSYNC. Na przykład, jeśli okres odświeżania to 16,7 ms (60 kl./s), możesz usunąć klatkę, gdy opóźnienie przekroczy 15 ms.

Jeśli spojrzysz na aplikację „Record GL”, zobaczysz zwiększenie licznika klatek, a nawet miganie czerwonego obramowania w przypadku spadku liczby klatek. Jeśli jednak oczy nie będą zbyt dobre, animacja nie będzie zacinać się. Przy 60 klatkach na sekundę aplikacja może od czasu do czasu pomijać klatkę, a jednocześnie nie zauważyć, o ile reklama rozwija się w stałym tempie. To, ile da się uniknąć, zależy w pewnym stopniu od tego, co rysujesz, cech wyświetlacza oraz od tego, jak dobrze osoba korzystająca z aplikacji wykrywa zacięty.

Zarządzanie wątkami

Ogólnie rzecz biorąc, jeśli renderujesz renderowanie w obiektach SurfaceView, GLSurfaceView lub TextureView, powinno się to odbywać w dedykowanym wątku. Nigdy nie rób żadnych „ciężkich czynności” ani niczego, co zajmuje nieokreśloną ilość czasu w wątku interfejsu. Zamiast tego utwórz dwa wątki dotyczące gry: wątek gry i wątek renderowania. Więcej informacji znajdziesz w artykule Zwiększanie wydajności gry.

Podziały i aplikacja „Record GL” korzystają z oddzielnych wątków mechanizmu renderowania, a także aktualizują stan animacji w tym wątku. To rozsądne podejście, o ile stan gry może być szybko aktualizowany.

Inne gry całkowicie oddzielają logikę gry od renderowania. Jeśli masz prostą grę, która nie robi nic poza przesuwaniem bloku co 100 ms, możesz mieć osobny wątek, który właśnie o tym informował:

run() {
    Thread.sleep(100);
    synchronized (mLock) {
        moveBlock();
    }
}

(Być może chcesz, aby czas snu był oparty na stałym zegarze, aby uniknąć dryfu, a sleep() nie jest spójny, a moveBlock() zajmuje niezerową ilość czasu, ale już pewnie się do tego przyzwyczaisz.)

Po wybudzeniu kodu rysowania chwyta zaczep, ustawia jego bieżące położenie, otwiera blokadę i rysuje. Zamiast wykonywać ruch ułamkowy na podstawie czasów delta między ramkami, mamy jeden wątek, który przesuwa elementy, i drugi, który rysuje wszystko w miejscu, w którym w momencie rozpoczęcia rysowania.

W przypadku takiej sceny warto utworzyć listę nadchodzących wydarzeń uporządkowaną według czasu pobudki i sen do następnego zdarzenia, ale tu jest ten sam pomysł.