Optimización de renderizado para dispositivos móviles

¡Hola queridos lectores, amantes y profesionales de la programación gráfica! Le presentamos una serie de artículos dedicados a la optimización del renderizado para dispositivos móviles: teléfonos y tabletas basados ​​en iOS y Android. El ciclo constará de tres partes. En la primera parte, examinaremos las características de la popular arquitectura de mosaico de GPU en dispositivos móviles . En el segundo, repasaremos las principales familias de GPU presentadas en dispositivos modernos y consideraremos sus fortalezas y debilidades. En la tercera parte, nos familiarizaremos con las características de la optimización del sombreador.

Entonces, pasemos a la primera parte.

El desarrollo de tarjetas de video en computadoras de escritorio y consolas se llevó a cabo en ausencia de restricciones significativas en el consumo de energía. Con la llegada de las tarjetas de video para dispositivos móviles, los ingenieros se enfrentaron a la tarea de garantizar un rendimiento aceptable con resoluciones de escritorio comparables, mientras que el consumo de energía de dichas tarjetas de video debería ser 2 órdenes de magnitud menor. 



La solución se encontró en una arquitectura especial llamada Tile Based Rendering (TBR) . Para un programador de gráficos con experiencia en desarrollo de PC, cuando se familiariza con el desarrollo móvil, todo parece familiar: se utiliza una API OpenGL ES similar, la misma estructura de la tubería de gráficos. Sin embargo, la arquitectura de mosaico de las GPU móviles es significativamente diferente de la utilizada en las consolas de PC / Modo inmediato . Conocer las fortalezas y debilidades de TBR lo ayudará a tomar las decisiones correctas y a obtener un excelente rendimiento con Mobile.

A continuación se muestra un diagrama simplificado de una tubería gráfica clásica utilizada en PC y consolas durante la tercera década.


En la etapa de procesamiento de geometría, los atributos de vértice se leen desde la memoria de video de la GPU. Después de varias transformaciones (Vertex Shader), las primitivas listas para renderizar en el orden original (FIFO) se pasan al rasterizador, que divide las primitivas en píxeles. Después de eso, se lleva a cabo la etapa de procesamiento de fragmentos de cada píxel (Fragment Shader) y los valores de color obtenidos se escriben en el búfer de pantalla, que también se encuentra en la memoria de video. Una característica de la arquitectura tradicional del "Modo inmediato" es la grabación del resultado del Fragment Shader en secciones arbitrarias del búfer de pantalla al procesar una sola llamada de dibujo. Por lo tanto, para cada llamada de sorteo, se puede requerir el acceso al búfer de pantalla completo. Trabajar con una gran variedad de memoria requiere un ancho de banda de bus apropiado ( ancho de banda ) y está asociado con un alto consumo de energía. Por lo tanto, las GPU móviles comenzaron a adoptar un enfoque diferente. En la arquitectura de mosaico típica de las tarjetas de video móviles, la representación se realiza en un pequeño trozo de memoria correspondiente a la parte de la pantalla: el mosaico. El tamaño pequeño del mosaico (por ejemplo, 16x16 píxeles para tarjetas de video Mali, 32x32 para PowerVR) le permite colocarlo directamente en el chip de la tarjeta de video, lo que hace que la velocidad de acceso sea comparable a la velocidad de acceso a los registros centrales del sombreador, es decir. muy rapido.


Sin embargo, dado que las primitivas pueden caer en secciones arbitrarias del búfer de pantalla, y el mosaico cubre solo una pequeña parte, se requirió un paso adicional en la tubería de gráficos. El siguiente es un diagrama simplificado de cómo funciona la tubería con la arquitectura de mosaico.


Después de procesar los vértices y construir las primitivas, estas últimas, en lugar de enviarse a la tubería de fragmentos, caen en el llamado Tiler . Aquí las primitivas se distribuyen por mosaicos, en los píxeles de los cuales caen. Después de dicha distribución, que, por regla general, cubre todas las llamadas de extracción dirigidas a un objeto Frame Buffer (también conocido como Render Target ), los mosaicos se representan secuencialmente. Para cada mosaico, se realiza la siguiente secuencia de acciones:

  1. Carga de contenido antiguo de FBO desde la memoria del sistema ( carga
  2. Render de primitivas que caen en este azulejo
  3. Carga de nuevo contenido de FBO en la memoria del sistema ( Store )


Cabe señalar que la operación de carga se puede considerar como una superposición adicional de la "textura de pantalla completa" sin compresión. Si es posible, evite esta operación, es decir No permita que FBO cambie de un lado a otro. Si se borra todo su contenido antes de renderizar en FBO , no se realiza la operación de carga . Sin embargo, para enviar la señal correcta al conductor, los parámetros de dicha limpieza deben cumplir ciertos criterios:

  1. Debe estar deshabilitado Rect de tijera
  2. Se debe permitir la grabación en todos los canales de color y alfa.

Para evitar la operación de carga para el búfer de profundidad y la plantilla, también deben limpiarse antes del inicio del renderizado.

También es posible evitar la operación de almacenamiento para el búfer de profundidad / plantilla. Después de todo, el contenido de estos búferes no se muestra de ninguna manera en la pantalla. Antes de la operación glSwapBuffers , puede llamar a glDiscardFramebufferEXT o glInvalidateFramebuffer

const GLenum attachments[] = {GL_DEPTH_ATTACHMENT, GL_STENCIL_ATTACHMENT};
glDiscardFramebufferEXT (GL_FRAMEBUFFER, 2, attachments);

const GLenum attachments[] = {GL_DEPTH_ATTACHMENT, GL_STENCIL_ATTACHMENT};
glInvalidateFramebuffer(GL_FRAMEBUFFER, 2, attachments);

Hay escenarios de representación en los que no se requiere la colocación de buffers de profundidad / stencil, así como buffers MSAA en la memoria del sistema. Por ejemplo, si el renderizado en el FBO con el búfer de profundidad es continuo, y la información de profundidad del fotograma anterior no se utiliza, entonces el búfer de profundidad no necesita cargarse en la memoria de mosaico antes del inicio del renderizado, o descargarse después de completar el renderizado. Por lo tanto, la memoria del sistema no se puede asignar bajo el búfer de profundidad. Las API gráficas modernas como Vulkan y Metal le permiten establecer explícitamente el modo de memoria para sus contrapartes FBO  ( MTLStorageModeMemoryless en Metal, VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT + VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT en Vulkan ).

De particular interés es la implementación de MSAA en arquitecturas de mosaico. El búfer de alta resolución para el MSAA no deja la memoria de mosaico al dividir el FBO en más mosaicos. Por ejemplo, para MSAA 2x2, los mosaicos de 16x16 se resolverán como 8x8 durante la operación de almacenamiento , es decir En total, será necesario procesar 4 veces más fichas. Pero no se requiere memoria adicional para MSAA , y debido a la representación en la memoria rápida de mosaico no habrá restricciones significativas de ancho de banda. Sin embargo usoMSAA en arquitectura de mosaico aumenta la carga en Tiler, lo que puede afectar negativamente el rendimiento de renderizado de escenas con mucha geometría.

Resumiendo lo anterior, presentamos el esquema deseado de trabajar con FBO en la arquitectura de mosaico:

// 1.   ,    auxFBO
glBindFramebuffer(GL_FRAMEBUFFER, auxFBO);
glDisable(GL_SCISSOR);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glDepthMask(GL_TRUE);
// glClear,     
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | 
           GL_STENCIL_BUFFER_BIT);

renderAuxFBO();         

//   /      
glInvalidateFramebuffer(GL_FRAMEBUFFER, 2, depth_and_stencil);
// 2.   mainFBO
glBindFramebuffer(GL_FRAMEBUFFER, mainFBO);
glDisable(GL_SCISSOR);

glClear(...);
//   mainFBO    auxFBO
renderMainFBO(auxFBO);

glInvalidateFramebuffer(GL_FRAMEBUFFER, 2, depth_and_stencil);

Si cambia a renderizado auxFBO en medio de la formación mainFBO , puede obtener operaciones innecesarias de carga y almacenamiento , lo que puede aumentar significativamente el tiempo de formación de cuadros. En nuestra práctica, encontramos una desaceleración en el renderizado incluso en el caso de configuraciones de FBO inactivas, es decir, sin el render real en ellos. Debido a la arquitectura del motor, nuestro antiguo circuito se veía así:

//   mainFBO
glBindFramebuffer(GL_FRAMEBUFFER, mainFBO);
//   
glBindFramebuffer(GL_FRAMEBUFFER, auxFBO);
//  auxFBO
renderAuxFBO();

glBindFramebuffer(GL_FRAMEBUFFER, mainFBO);
//   mainFBO
renderMainFBO(auxFBO);

A pesar de la falta de llamadas gl después de la primera instalación de mainFBO , en algunos dispositivos obtuvimos operaciones adicionales de carga y almacenamiento y un peor rendimiento.

Para mejorar nuestra comprensión de la sobrecarga por el uso de FBO intermedios , medimos la pérdida de tiempo para cambiar FBO de pantalla completa mediante una prueba sintética. La tabla muestra el tiempo dedicado a la operación de almacenamiento al cambiar FBO varias veces en un cuadro (se proporciona el tiempo de una de esas operaciones). Operación de carga ausente debido a glCleares decir Se midió un escenario más favorable. El permiso utilizado en el dispositivo contribuyó. Podría corresponder más o menos a la potencia de la GPU instalada. Por lo tanto, estas cifras solo dan una idea general de cuán costoso es el cambio de objetivos en tarjetas de video móviles de varias generaciones.
GPUmilisegundosGPUmilisegundos
Adreno 3205.2
Adreno 5120,74
PowerVR G62003,3Adreno 6150.7
Mali-4003.2Adreno 5300.4 0.4
Mali-t7201.9Mali-g510,32
PowerVR SXG 5441.4Mali-t830
0,15

Con base en los datos obtenidos, podemos recomendar que no se usen más de uno o dos interruptores FBO por cuadro, al menos para tarjetas de video más antiguas. Si el juego tiene un código separado para dispositivos Low-End, es aconsejable no usar el cambio FBO allí. Sin embargo, en el Low-End, el problema de reducir la resolución a menudo se vuelve relevante. En Android, puede reducir la resolución de representación sin recurrir al uso de un FBO intermedio llamando a SurfaceHolder.setFixedSize ():

surfaceView.getHolder().setFixedSize(...)

Este método no funcionará si el juego se procesa a través de la aplicación principal de Surface (un esquema típico para trabajar con NativeActivity ). Si usa la superficie principal , se puede establecer una resolución más baja llamando a la función nativa ANativeWindow_setBuffersGeometry.

JNIEXPORT void JNICALL Java_com_organization_app_AppNativeActivity_setBufferGeometry(JNIEnv *env, jobject thiz, jobject surface, jint width, jint height)
{
ANativeWindow* window = ANativeWindow_fromSurface(env, surface); 
ANativeWindow_setBuffersGeometry(window, width, height, AHARDWAREBUFFER_FORMAT_R8G8B8X8_UNORM); 
}

En Java:

private static native void setBufferGeometry(Surface surface, int width , int height )
...
//   SurfaceHolder.Callback
@Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height)
{
     setBufferGeometry(holder.getSurface(), 768, 1366); /* ... */
...

Finalmente, mencionamos el conveniente comando ADB para controlar los búferes de superficie seleccionados en Android:

adb shell dumpsys surfaceflinger

Puede obtener una conclusión similar que le permite estimar el consumo de memoria para los búferes de superficie:


La captura de pantalla muestra el sistema resaltando 3 buffers para el triple buffering del juego GLSurfaceView (resaltado en amarillo), así como 2 buffers para la superficie principal (resaltado en rojo). En el caso de renderizar a través de la Surface principal, que es el esquema predeterminado cuando se usa NativeActivity , se puede evitar la asignación de buffers adicionales. 

Eso es todo por ahora. En los siguientes artículos, clasificaremos las GPU móviles y analizaremos los métodos para optimizar los sombreadores para ellas.

All Articles