Informationen zum Rendering in Spielschleifen

Eine sehr beliebte Möglichkeit zur Implementierung einer Spielschleife sieht so aus:

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

Dabei gibt es einige Probleme. Das grundlegendste ist die Idee, dass das Spiel definieren kann, was ein „Frame“ ist. Verschiedene Displays werden mit unterschiedlichen Raten aktualisiert, die sich im Laufe der Zeit ändern können. Wenn Sie Frames schneller generieren, als sie in der Anzeige dargestellt werden können, müssen Sie gelegentlich einen löschen. Werden sie zu langsam generiert, findet SurfaceFlinger in regelmäßigen Abständen keinen neuen Zwischenspeicher und zeigt den vorherigen Frame noch einmal an. Beide Situationen können sichtbare Störungen verursachen.

Sie müssen die Framerate des Displays angleichen und den Spielstatus entsprechend der seit dem vorherigen Frame verstrichenen Zeit fortsetzen. Dafür gibt es mehrere Möglichkeiten:

  • Android Frame Pacing-Bibliothek verwenden (empfohlen)
  • Füllen Sie die BufferQueue voll aus und verlassen Sie sich auf den Gegendruck von „Swap-Puffer“.
  • Choreograf verwenden (API 16 und höher)

Android Frame Pacing-Bibliothek

Informationen zur Verwendung dieser Bibliothek finden Sie unter Richtige Frame-Taktung erreichen.

Warteschlangen

Die Implementierung ist ganz einfach: Tauschen Sie die Puffer so schnell wie möglich aus. In frühen Android-Versionen kann dies sogar zu einer Strafe führen, bei der SurfaceView#lockCanvas() dich 100 ms lang in den Ruhemodus versetzt hat. Jetzt wird sie von „BufferQueue“ getaktet und BufferQueue wird so schnell wie OberflächenFlinger geleert.

Ein Beispiel für diesen Ansatz findest du in der Android-Aufschlüsselung. Dabei wird GLSurfaceView verwendet, das in einer Schleife ausgeführt wird, die den onDrawFrame()-Callback der Anwendung aufruft und dann den Zwischenspeicher austauscht. Wenn die BufferQueue voll ist, wartet der eglSwapBuffers()-Aufruf, bis ein Puffer verfügbar ist. Puffer sind verfügbar, wenn SurfaceFlinger sie freigibt, also nachdem ein neuer Puffer für die Anzeige angeschafft wurde. Da dies bei VSYNC geschieht, entspricht die Zeitschleife der Zeichenschleife der Aktualisierungsrate. Meistens.

Bei diesem Ansatz gibt es einige Probleme. Erstens ist die App mit OberflächenFlinger-Aktivitäten verknüpft, die unterschiedlich viel Zeit in Anspruch nehmen, je nachdem, wie viel Arbeit zu erledigen ist und ob die CPU-Zeit bei anderen Prozessen beansprucht wird. Da sich der Spielstatus in der Zeit zwischen den Zwischenspeichertauschen entsprechend weiterentwickelt, wird die Animation nicht gleichmäßig aktualisiert. Wenn Sie jedoch mit 60 fps laufen und die Inkonsistenzen im Laufe der Zeit gemittelt wurden, werden Sie die Ausschläge wahrscheinlich nicht bemerken.

Zweitens werden die ersten Pufferaustausche sehr schnell durchgeführt, da die BufferQueue noch nicht voll ist. Die berechnete Zeit zwischen den Frames liegt nahe null, sodass das Spiel einige Frames generiert, in denen nichts passiert. Bei einem Spiel wie Breakout, bei dem der Bildschirm bei jeder Aktualisierung aktualisiert wird, ist die Warteschlange immer voll, außer wenn ein Spiel zum ersten Mal gestartet oder wieder aktiviert wird. Daher ist der Effekt nicht spürbar. Bei einem Spiel, das Animationen gelegentlich pausiert und dann in den schnellstmöglichen Modus zurückkehrt, kann es zu seltsamen Fehlern kommen.

Choreographer

Mit Choreographer können Sie einen Callback festlegen, der bei der nächsten VSYNC ausgelöst wird. Die tatsächliche VSYNC-Zeit wird als Argument übergeben. Selbst wenn Ihre App nicht sofort aktiviert wird, haben Sie trotzdem ein genaues Bild vom Beginn der Aktualisierungsphase. Die Verwendung dieses Werts anstelle der aktuellen Uhrzeit führt zu einer konsistenten Zeitquelle für die Logik für die Aktualisierung des Spielstatus.

Die Tatsache, dass Sie nach jeder VSYNC-Anfrage einen Callback erhalten, garantiert nicht, dass Ihr Callback zügig ausgeführt wird oder dass Sie ausreichend schnell reagieren können. Ihre App muss Situationen erkennen, in denen sie hinterherhinkt, und Frames manuell setzen.

Die Aktivität „GL-App aufzeichnen“ in Grafika ist ein Beispiel dafür. Auf einigen Geräten (z.B. Nexus 4 und Nexus 5) werden Frames entfernt, wenn Sie sich einfach hinsetzen und zuschauen. Das GL-Rendering ist einfach, aber gelegentlich werden die View-Elemente neu gezeichnet. Außerdem kann die Messung/das Layout-Ticket sehr lange dauern, wenn das Gerät in den Modus mit geringerer Leistung versetzt wurde. Laut Systemtrace dauert es unter Android 4.4 28 ms statt 6 ms, wenn die Uhr langsam ist. Wenn Sie Ihren Finger über den Bildschirm ziehen, glaubt er, dass Sie mit der Aktivität interagieren. Die Taktgeschwindigkeit bleibt dann hoch und Sie setzen keinen Frame.)

Die einfache Lösung bestand darin, einen Frame im Choreographer-Callback zu löschen, wenn die aktuelle Zeit mehr als N Millisekunden nach der VSYNC-Zeit liegt. Idealerweise wird der Wert von N anhand der zuvor beobachteten VSYNC-Intervalle bestimmt. Beträgt der Aktualisierungszeitraum beispielsweise 16,7 ms (60 fps), können Sie einen Frame auslassen, wenn die Aktualisierung mehr als 15 ms zu spät erfolgt.

Wenn Sie die Aufzeichnung von GL-Apps beobachten, sehen Sie die Erhöhung des Zählers für abgebrochene Frames. Im Rahmen ist sogar ein roter Blitz zu sehen, wenn Frames ausfallen. Solange Ihre Augen nicht gut sind, ruckelt die Animation nicht. Bei 60 fps kann die App gelegentliche Frames löschen, ohne dass es jemand bemerkt, solange die Animation mit einer konstanten Geschwindigkeit voranschreitet. Wie viel Sie damit erreichen können, hängt zu einem gewissen Grad davon ab, was Sie zeichnen, von den Eigenschaften des Displays und davon, wie gut die Person, die die App verwendet, Verzögerungen erkennt.

Thread-Verwaltung

Wenn Sie auf einer SurfaceView, GLSurfaceView oder TextureView rendern, sollten Sie dies im Allgemeinen in einem speziellen Thread tun. Erledigen Sie keine schweren Aufgaben oder Aufgaben, die unbestimmte Zeit für den UI-Thread in Anspruch nehmen. Erstellen Sie stattdessen zwei Threads für das Spiel: einen Spiele-Thread und einen Rendering-Thread. Weitere Informationen finden Sie unter Leistung verbessern.

Breakout und „Record GL app“ verwenden dedizierte Renderer-Threads und aktualisieren auch den Animationsstatus in diesem Thread. Dies ist ein vernünftiger Ansatz, solange der Spielstatus schnell aktualisiert werden kann.

Bei anderen Spielen werden Spiellogik und Rendering vollständig voneinander getrennt. Wenn Sie ein einfaches Spiel haben, das nur alle 100 ms einen Block verschoben hat, könnten Sie einen dedizierten Thread haben, der genau so funktioniert:

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

(Möglicherweise möchten Sie die Schlafzeit auf einer festen Uhr basieren, um eine Drift zu verhindern – sleep() ist nicht perfekt konsistent, und MoveBlock() nimmt eine Zeit ungleich null in Anspruch – aber Sie haben den Eindruck.)

Wenn der Zeichencode aktiviert wird, greift er einfach auf das Schloss, ruft die aktuelle Position des Blocks ab, löst die Sperre aus und zeichnet den Text. Anstatt Bruchbewegungen basierend auf Delta-Zeiten zwischen Frames auszuführen, haben Sie nur einen Thread, der die Dinge voranbewegt, und einen anderen, der die Dinge überall dort zeichnet, wo sie gerade zu Beginn der Zeichnung sind.

Für eine Szene mit beliebiger Komplexität sollten Sie eine Liste anstehender Ereignisse erstellen, sortiert nach Aufwachzeit und Schlafen, bis der nächste Termin fällig ist, aber das ist dieselbe.