Una forma muy popular de implementar un bucle de juego es la siguiente:
while (playing) {
advance state by one frame
render the new frame
sleep until it’s time to do the next frame
}
Esta forma presenta algunos problemas: el principal es la idea de que el juego puede definir qué es un "fotograma". Cada pantalla se actualiza con una frecuencia diferente, y esta puede variar con el tiempo. Si generas fotogramas a una velocidad mayor que la que puede alcanzar la pantalla para mostrarlos, tendrás que descartar alguno ocasionalmente. Si los generas demasiado lento, SurfaceFlinger tendrá problemas para encontrar un nuevo búfer que adquirir y volverá a mostrar el fotograma previo. Ambas situaciones pueden causar fallas visibles.
Lo que debes lograr es que coincida la velocidad de fotogramas de la pantalla y el estado de avance del juego según el tiempo transcurrido desde el fotograma anterior. Hay varias formas de lograrlo:
- Usar la biblioteca de Android Frame Pacing (recomendado)
- Rellenar BufferQueue por completo y confiar en la contrapresión de los "búferes de intercambio"
- Usar Choreographer (nivel de API 16 o posterior)
Biblioteca de Android Frame Pacing
Consulta Cómo lograr un ritmo de fotogramas adecuado para obtener información sobre el uso de esta biblioteca.
Relleno de filas
Es muy fácil de implementar: solo necesitas intercambiar los búferes lo más rápido posible. En las primeras versiones de Android, esto podría dar como resultado una penalización en la que SurfaceView#lockCanvas()
te suspendería durante 100 ms. Ahora, el ritmo lo controla BufferQueue, que se vacía tan rápido como lo permite SurfaceFlinger.
Puedes ver un ejemplo de este enfoque en Android Breakout. Utiliza GLSurfaceView, que se ejecuta en un bucle que llama a la devolución de llamada onDrawFrame() de la aplicación y, luego, intercambia el búfer. Si BufferQueue está llena, la llamada a eglSwapBuffers()
esperará hasta que haya un búfer disponible.
Los búferes están disponibles cuando SurfaceFlinger los libera, y lo hace después de adquirir uno nuevo para su visualización. Debido a que esto sucede en VSYNC, el ritmo con el que se dibuja el bucle coincide con la frecuencia de actualización casi siempre.
Este enfoque tiene algunos problemas. En primer lugar, la app está vinculada a la actividad de SurfaceFlinger, que toma distintos períodos según la cantidad de trabajo por hacer, y según si tiene que compartir el tiempo de CPU con otros procesos. Como el estado de tu juego avanza en función del tiempo entre los intercambios de búfer, la animación no se actualiza a una velocidad constante. Sin embargo, si lo ejecutas a 60 FPS con las inconsistencias distribuidas a lo largo del tiempo, es probable que no se noten los errores.
En segundo lugar, los primeros intercambios de búfer serán muy rápidos, porque BufferQueue aún no estará llena. El tiempo calculado entre los fotogramas se acercará a cero, por lo que el juego generará algunos fotogramas en los que no sucede nada. En un juego como Breakout, que actualiza la pantalla en cada actualización, la cola siempre está llena, excepto cuando se inicia o reanuda una partida, por lo que el efecto no es visible. Un juego que pausa la animación ocasionalmente y, luego, vuelve a reproducirla tan rápido como es posible puede experimentar algunos errores.
Coreógrafo
Choreographer te permite configurar una devolución de llamada que se activa en la próxima VSYNC. El tiempo real de VSYNC se pasa como un argumento. Por lo tanto, incluso si tu app no se activa de inmediato, tienes una imagen precisa del momento en el que comenzó el período de actualización de la pantalla. El uso de este valor, en lugar del tiempo actual, representa una fuente de tiempo coherente para la lógica de actualización del estado del juego.
Lamentablemente, recibir una devolución de llamada después de cada VSYNC no garantiza que tu devolución de llamada se ejecute de manera oportuna o que puedas actuar sobre ella con suficiente rapidez. Tu app deberá detectar situaciones de retrasos y descartar fotogramas de forma manual.
La actividad de la "Record GL app" en Grafika sirve de ejemplo. En algunos dispositivos (como Nexus 4 y Nexus 5), la actividad comenzará a descartar fotogramas si la dejas funcionar por su cuenta. La renderización en GL es trivial, pero, a veces, los elementos View se vuelven a dibujar, y el pase de medición y diseño puede tomar mucho tiempo si el dispositivo pasó a modo de bajo consumo. (Según systrace, una vez que los relojes se ralentizan en Android 4.4, se necesitan 28 ms en lugar de 6 ms. Si arrastras el dedo por la pantalla, el dispositivo cree que estás interactuando con la actividad, por lo que el reloj no baja la velocidad y no se descarta ningún fotograma).
La solución simple es descartar un fotograma en la devolución de llamada de Choreographer si el tiempo actual es superior a N milisegundos después del tiempo de VSYNC. Idealmente, el valor de N se determina en función de intervalos de VSYNC previos. Por ejemplo, si el período de actualización es 16.7 ms (60 FPS), puedes descartar un fotograma si estás tardando más de 15 ms.
Si miras cómo se ejecuta la "Record GL app", verás que la cantidad de fotogramas descartados aumenta, e incluso se ve un destello rojo en el borde cuando se descarta un fotograma. Sin embargo, a menos que tu visión sea muy buena, no verás saltos en la animación. A 60 FPS, la app puede descartar algunos fotogramas sin que nadie lo note, siempre y cuando la animación avance a una velocidad constante. La cantidad que puedes descargar depende en parte de lo que estés dibujando, las características de la pantalla y lo bien que la persona que usa la app detecte bloqueos.
Administración de subprocesos
En términos generales, si ejecutas un proceso en SurfaceView, GLSurfaceView o TextureView, es mejor hacerlo en un subproceso especializado. No hagas esfuerzos grandes ni nada que tome un tiempo indeterminado en el subproceso de IU. Es mejor crear dos subprocesos para el juego: uno de juego y uno de procesamiento. Consulta Cómo mejorar el rendimiento de tu juego para obtener más información.
Breakout y la "Record GL app" usan subprocesos de procesador especializados y también actualizan el estado de la animación en ese subproceso. Este es un enfoque razonable, siempre que el estado del juego se pueda actualizar rápidamente.
Otros juegos separan por completo la lógica y el procesamiento. Si tuvieras un juego simple, en el que no se hace nada más que mover un bloque cada 100 ms, podrías tener un subproceso especializado que solo hiciera lo siguiente:
run() {
Thread.sleep(100);
synchronized (mLock) {
moveBlock();
}
}
(Es recomendable basar el tiempo de suspensión en un reloj fijo para evitar desvíos: sleep() no siempre es coherente, y moveBlock() tarda mucho tiempo, pero se entiende).
Cuando se activa el código para dibujar, este busca el bloqueo, encuentra la posición del bloque en ese momento, lo desbloquea y, luego, dibuja. En lugar de realizar movimientos fraccionarios en base a los tiempos delta de los fotogramas, solo hay un subproceso que hace que todo avance y otro que dibuja las cosas en donde sea que se encuentren cuando comienza el dibujo.
Para las escenas más complejas, te recomendamos que crees una lista de los próximos eventos organizados según el orden en el que se activan, y dejarlos desactivados hasta el próximo evento, pero la idea es la misma.