Saiba mais sobre renderização de loops de jogos

Uma forma muito comum de implementar um loop de jogo é esta:

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

Há alguns problemas com isso, e o mais fundamental é a ideia de que o jogo pode definir o que é um "frame". Telas diferentes serão atualizadas em taxas diferentes, e essa taxa pode variar com o tempo. Se você gerar frames mais rapidamente do que a tela pode exibi-los, será necessário descartar um frame ocasionalmente. Se você gerá-los muito lentamente, o SurfaceFlinger periodicamente não conseguirá encontrar um novo buffer para usar e mostrará o frame anterior mais uma vez. Ambas as situações podem causar falhas visíveis.

É necessário se igualar ao frame rate da tela e avançar o estado do jogo de acordo com o tempo decorrido desde o frame anterior. Há várias maneiras de fazer isso:

  • Usar a biblioteca Android Frame Pacing (recomendado)
  • Preencher totalmente o BufferQueue e usar a pressão de retorno dos buffers de troca
  • Usar o Choreographer (API 16 ou posterior)

Biblioteca Android Frame Pacing

Consulte Conseguir um ritmo adequado de frames para ver mais informações sobre como usar essa biblioteca.

Excesso de filas

Essa implementação é muito fácil: basta trocar os buffers o mais rápido possível. Nas primeiras versões do Android, isso poderia resultar em uma penalidade em que SurfaceView#lockCanvas() colocaria você no modo de suspensão por 100 ms. Agora, ele é ritmado pelo BufferQueue, que é esvaziado de acordo com a capacidade do SurfaceFlinger.

Um exemplo dessa abordagem pode ser visto no Android Breakout(link em inglês). Ele usa GLSurfaceView, que é executado em um loop que chama o callback onDrawFrame() do aplicativo e troca o buffer. Se o BufferQueue estiver cheio, a chamada de eglSwapBuffers() aguardará até que um buffer esteja disponível. Os buffers ficam disponíveis quando o SurfaceFlinger os libera, o que ele faz depois de adquirir um novo para a exibição. Como isso acontece no VSYNC, seu tempo de loop de desenho corresponderá à taxa de atualização. Na maioria das vezes.

Essa abordagem tem alguns problemas. Primeiro, o app está vinculado à atividade do SurfaceFlinger, que levará um tempo diferente, dependendo de quanto trabalho precisa ser feito e se ela está concorrendo pelo tempo de CPU com outros processos. Como o estado do jogo avança de acordo com o tempo entre as trocas de buffer, a animação não será atualizada a uma taxa consistente. No entanto, ao executar a 60 fps com as inconsistências na média ao longo do tempo, você provavelmente não notará as imperfeições.

Em segundo lugar, as primeiras trocas de buffer acontecerão muito rapidamente, porque o BufferQueue ainda não está cheio. O tempo computado entre os frames será próximo a zero, de modo que o jogo gerará alguns frames em que nada acontece. Em um jogo como o Breakout, que atualiza a tela a cada renovação, a fila estará sempre cheia, exceto quando um jogo for iniciado pela primeira vez (ou retomado), então o efeito não é perceptível. Um jogo que pausa a animação ocasionalmente e depois retorna ao modo "mais rápido possível" pode apresentar algumas falhas estranhas.

Choreographer

O Choreographer permite que você defina um callback que é disparado no próximo VSYNC. O tempo VSYNC real é transmitido como um argumento. Assim, mesmo que seu app não seja ativado de imediato, você ainda terá uma ideia precisa do momento de início do período de atualização da tela. O uso desse valor, em vez do horário atual, gera uma fonte de tempo consistente para sua lógica de atualização de estado do jogo.

Infelizmente, o fato de você receber um callback após cada VSYNC não garante que ele será executado em tempo hábil ou que você poderá agir com rapidez suficiente. O app precisará detectar situações em que está atrasado e descartar frames manualmente.

A atividade "Record GL app" no Grafika oferece um exemplo disso. Em alguns dispositivos (por exemplo, Nexus 4 e Nexus 5), a atividade começará a descartar frames se você não fizer nada. A renderização de GL é trivial, mas ocasionalmente os elementos View são redesenhados e a transmissão de medida/layout pode demorar muito se o dispositivo está em um modo de energia reduzida. De acordo com o Systrace, ela leva 28 ms em vez de 6 ms com os relógios mais lentos no Android 4.4. Se você arrastar o dedo pela tela, isso será interpretado como uma interação com a atividade, então a velocidade do relógio permanecerá alta e você nunca descartará um frame.

A correção simples seria descartar um frame no callback do Choreographer se o tempo atual for mais de N milissegundos após o do VSYNC. O ideal é que o valor de N seja determinado com base em intervalos do VSYNC observados anteriormente. Por exemplo, se o período de atualização for 16,7 ms (60 fps), você poderá descartar um frame se estiver executando com mais de 15 ms de atraso.

Se você observar a execução "Record GL app", verá o contador de frames descartados aumentando e até um flash vermelho na borda quando houver descarte de frames. No entanto, a menos que você seja especialista, não perceberá a renderização lenta da animação. Com 60 fps, o app pode descartar o frame ocasionalmente sem que alguém perceba, desde que a animação continue avançando a uma taxa constante. O quanto pode passar despercebido depende muito do que você está desenhando, das características da tela e do nível de conhecimento da pessoa que está usando o app para detectar instabilidade.

Gerenciamento de linhas de execução

De modo geral, se você estiver renderizando em SurfaceView, GLSurfaceView ou TextureView, é recomendável fazer essa renderização em uma linha de execução dedicada. Nunca faça nada complicado ou algo que leve um tempo indeterminado na linha de execução de IU. Em vez disso, crie duas linhas de execução para o jogo: uma de jogo e uma de renderização. Consulte Melhorar o desempenho do seu jogo para ver mais informações.

O Breakout e o "Record GL app" usam linhas de execução do renderizador dedicadas, além de atualizar o estado da animação nessa linha de execução. Essa é uma abordagem razoável, desde que o estado do jogo possa ser atualizado rapidamente.

Outros jogos separam completamente a lógica do jogo e a renderização. Se você tivesse um jogo simples que não fizesse nada além de mover um bloco a cada 100 ms, poderia ter uma linha de execução dedicada que fizesse exatamente isso:

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

É recomendável basear o tempo de suspensão de um relógio fixo para evitar deslocamento. O sleep() não é perfeitamente consistente, e moveBlock() usa um tempo diferente de zero, mas a ideia é essa.

Quando o código de desenho é ativado, ele apenas pega o bloqueio, verifica a posição atual do bloco, libera o bloqueio e desenha. Em vez de fazer a movimentação fracionária com base nos tempos delta entre frames, você tem apenas uma linha de execução que move os elementos e outra que desenha tudo onde quer que ela esteja quando o desenho começa.

Para uma cena com qualquer complexidade, você precisaria criar uma lista de eventos futuros, classificados pelo tempo de ativação, e suspender até o próximo evento, mas a ideia é a mesma.