Scopri di più sul rendering nei cicli di gioco

Un modo molto popolare per implementare un ciclo di gioco è simile al seguente:

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

Questo presenta alcuni problemi, il più fondamentale è l'idea che il gioco possa definire che cos'è un "frame". I diversi display si aggiornano a velocità diverse, che possono variare nel tempo. Se generi frame più velocemente di quanto possano essere visualizzati dal display, di tanto in tanto dovrai rimuoverne uno. Se li generi troppo lentamente, periodicamente SurfaceFlinger non riesce a trovare un nuovo buffer da acquisire e mostra di nuovo il frame precedente. Entrambe queste situazioni possono causare problemi visibili.

Quello che devi fare è far corrispondere la frequenza fotogrammi del display e lo stato di avanzamento del gioco in base al tempo trascorso dal frame precedente. Puoi procedere in diversi modi:

  • Utilizzare la libreria di pacing dei frame Android (opzione consigliata)
  • Riempi la BufferQueue piena e fai affidamento sulla pressione di ritorno degli "swap buffer"
  • Usa Coreografo (API 16 e versioni successive)

Raccolta del pacing dei frame Android

Consulta la sezione Ottenere il pacing del frame corretto per informazioni sull'utilizzo di questa libreria.

Coda in eccesso

È molto facile da implementare: basta scambiare i buffer il più velocemente possibile. Nelle prime versioni di Android, questo potrebbe comportare una penalità per cui SurfaceView#lockCanvas() ti faceva addormentare per 100 ms. Ora viene regolato dalla BufferQueue, che viene svuotata velocemente quanto riesce a SurfaceFlinger.

Un esempio di questo approccio può essere visto in Android Breakout. Utilizza GLSurfaceView, che viene eseguito in un loop che chiama il callback onDrawFrame() dell'applicazione e poi scambia il buffer. Se la coda del buffer è piena, la chiamata eglSwapBuffers() attende che sia disponibile un buffer. I buffer diventano disponibili quando SurfaceFlinger li rilascia, cosa che fa dopo l'acquisizione di un nuovo per la visualizzazione. Poiché questo avviene con VSYNC, la durata del disegno loop corrisponderà alla frequenza di aggiornamento. In gran parte.

Questo approccio presenta alcuni problemi. Innanzitutto, l'app è legata all'attività SurfaceFlinger, che richiederà tempi diversi a seconda della quantità di lavoro da svolgere e della quantità di tempo di CPU presente con altri processi. Poiché lo stato del gioco avanza in base all'intervallo di tempo tra gli scambi del buffer, l'animazione non viene aggiornata con una frequenza costante. Tuttavia, quando corri a 60 fps con le incongruenze calcolate nel tempo, probabilmente non noterai i picchi.

In secondo luogo, i primi due scambi del buffer avvengono molto rapidamente, perché la BufferQueue non è ancora piena. Il tempo calcolato tra i frame sarà vicino allo zero, quindi il gioco genererà alcuni frame in cui non succederà nulla. In un gioco come Breakout, che aggiorna lo schermo a ogni aggiornamento, la coda è sempre piena, tranne quando un gioco inizia (o viene riattivato per la prima volta), quindi l'effetto non è evidente. Un gioco che di tanto in tanto mette in pausa l'animazione e poi torna alla modalità il più veloce possibile potrebbe riscontrare strani problemi.

Choreographer

Choreographer ti consente di impostare un callback che si attiva alla successiva VSYNC. L'ora VSYNC effettiva viene passata come argomento. Pertanto, anche se l'app non si attiva subito, hai comunque un quadro preciso dell'inizio del periodo di aggiornamento del display. L'utilizzo di questo valore, anziché l'ora corrente, restituisce un'origine temporale coerente per la logica di aggiornamento dello stato del gioco.

Sfortunatamente, il fatto che tu riceva una chiamata dopo ogni VSYNC non garantisce che la richiamata venga eseguita in modo tempestivo o che tu possa intervenire in modo sufficientemente rapido. L'app dovrà rilevare le situazioni in cui rimane indietro e rilasciare i frame manualmente.

L'attività "Registra app GL" in Grafika ne fornisce un esempio. Su alcuni dispositivi (ad esempio Nexus 4 e Nexus 5), l'attività inizia a perdere frame se ti limiti a guardare. Il rendering GL è banale, ma a volte gli elementi View vengono ridisegnati e il passaggio di misura/layout può richiedere molto tempo se il dispositivo è passato a una modalità a consumo ridotto. (Secondo systrace, su Android 4.4 sono necessari 28 ms anziché 6 ms. Se trascini il dito sullo schermo, per avere un'idea dell'interazione con l'attività, la velocità dell'orologio rimane elevata e non lascerai mai un fotogramma.

La semplice soluzione è stata l'eliminazione di un frame nel callback Choreographer se il tempo corrente è superiore a N millisecondi dopo il tempo VSYNC. Idealmente, il valore di N viene determinato in base agli intervalli VSYNC osservati in precedenza. Ad esempio, se il periodo di aggiornamento è di 16,7 ms (60 fps), potresti perdere un frame se il ritardo è superiore a 15 ms.

Se guardi l'esecuzione di "Record GL app", vedrai il contatore dei frame rilasciati aumentare e persino un lampo di rosso sul bordo quando i frame cadono. Se non hai gli occhi molto bene, l'animazione non si interrompe mai. A 60 fps, l'app può rilasciare frame occasionali senza che nessuno se ne accorga, purché l'animazione continui ad avanzare a una velocità costante. I vantaggi che puoi raggiungere dipendono in qualche modo da ciò che stai disegnando, dalle caratteristiche del display e da quanto è brava la persona che utilizza l'app nel rilevare jank.

Gestione dei thread

In generale, se esegui il rendering su SurfaceView, GLSurfaceView o TextureView, vuoi eseguirlo in un thread dedicato. Non eseguire mai "sollevamenti pesanti" o qualsiasi altro tipo di attività che richieda un tempo indeterminato nel thread dell'interfaccia utente. Crea invece due thread per il gioco: un thread del gioco e uno di rendering. Per ulteriori informazioni, visita la pagina Migliorare le prestazioni del gioco.

Breakout e "Registra app GL" utilizzano thread del renderer dedicati e aggiornano anche lo stato dell'animazione in quel thread. Questo è un approccio ragionevole a condizione che lo stato del gioco possa essere aggiornato rapidamente.

Altri giochi separano completamente la logica e il rendering del gioco. Se avessi un gioco semplice che non ha fatto altro che spostare un blocco ogni 100 ms, potresti avere un thread dedicato che ha semplicemente fatto questo:

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

(Potresti voler basare il tempo di sonno su un orologio fisso per evitare deviazioni: sleep() non è perfettamente coerente e moveBlock() richiede una quantità di tempo diversa da zero, ma hai un'idea).

Quando il codice di disegno si attiva, afferra il blocco, ottiene la posizione corrente del blocco, rilascia il blocco e disegna. Invece di eseguire un movimento frazionario basato su tempi delta tra frame, hai a disposizione un thread che sposta gli elementi avanti e un altro che disegna gli oggetti ovunque si trovino all'inizio del disegno.

Per una scena con qualsiasi complessità, puoi creare un elenco di eventi imminenti ordinati per ora di sveglia e dormire fino alla scadenza del prossimo evento, ma è la stessa idea.