Otimização de renderização para celular

Olá queridos leitores, amantes e profissionais de programação gráfica! Chamamos a atenção para uma série de artigos dedicados à otimização da renderização para dispositivos móveis: telefones e tablets baseados em iOS e Android. O ciclo consistirá em três partes. Na primeira parte, examinaremos os recursos da popular arquitetura de blocos de GPU no celular . No segundo, abordaremos as principais famílias de GPUs apresentadas em dispositivos modernos e consideraremos seus pontos fortes e fracos. Na terceira parte, vamos nos familiarizar com os recursos da otimização de shader.

Então, vamos à primeira parte.

O desenvolvimento de placas de vídeo em desktops e consoles ocorreu na ausência de restrições significativas ao consumo de energia. Com o advento das placas de vídeo para dispositivos móveis, os engenheiros enfrentaram a tarefa de garantir um desempenho aceitável em resoluções de desktop comparáveis, enquanto o consumo de energia dessas placas de vídeo deveria ser 2 ordens de magnitude menor. 



A solução foi encontrada em uma arquitetura especial chamada Tile Based Rendering (TBR) . Para um programador gráfico com experiência em desenvolvimento de PC, quando ele se familiariza com o desenvolvimento móvel, tudo parece familiar: uma API OpenGL ES semelhante é usada, a mesma estrutura do pipeline gráfico. No entanto, a arquitetura de blocos de GPUs móveis é significativamente diferente daquela usada nos consoles PC / Modo Imediato . Conhecer os pontos fortes e fracos da TBR ajudará você a tomar as decisões corretas e obter um ótimo desempenho com o Mobile.

Abaixo está um diagrama simplificado de um pipeline gráfico clássico usado em PCs e consoles pela terceira década.


No estágio de processamento da geometria, os atributos do vértice são lidos na memória de vídeo da GPU. Após várias transformações (Vertex Shader), as primitivas prontas para renderizar na ordem original (FIFO) são passadas para o rasterizador, que divide as primitivas em pixels. Depois disso, é realizado o estágio de processamento do fragmento de cada pixel (Fragment Shader) e os valores de cores obtidos são gravados no buffer da tela, que também está localizado na memória de vídeo. Um recurso da arquitetura tradicional do “Modo Imediato” é a gravação do resultado do Fragment Shader em seções arbitrárias do buffer de tela ao processar uma única chamada de empate.. Assim, para cada chamada de empate, pode ser necessário acesso a todo o buffer da tela. Trabalhar com uma grande variedade de memória requer uma largura de banda de barramento apropriada ( largura de banda ) e está associado ao alto consumo de energia. Portanto, as GPUs móveis começaram a adotar uma abordagem diferente. Na arquitetura de blocos típica das placas de vídeo móveis, a renderização é feita em um pequeno pedaço de memória correspondente à parte da tela - o bloco. O tamanho pequeno do bloco (por exemplo, 16x16 pixels para placas de vídeo do Mali, 32x32 para PowerVR) permite colocá-lo diretamente no chip da placa de vídeo, o que torna a velocidade de acesso comparável à velocidade de acesso aos registros principais do shader, ou seja, muito rápido.


No entanto, como as primitivas podem cair em seções arbitrárias do buffer de tela e um bloco cobre apenas uma pequena parte dele, foi necessária uma etapa adicional no pipeline de gráficos. A seguir, é apresentado um diagrama simplificado de como o pipeline funciona com a arquitetura de blocos.


Depois de processar os vértices e construir as primitivas, as últimas, em vez de serem enviadas para o pipeline de fragmentos, caem no chamado Tiler . Aqui, as primitivas são distribuídas por blocos, nos pixels dos quais caem. Após essa distribuição, que, em regra, cobre todas as chamadas de desenho direcionadas a um objeto de buffer de quadro (também conhecido como destino de renderização ), os blocos são renderizados seqüencialmente. Para cada bloco, a seguinte sequência de ações é executada:

  1. Carregando conteúdo antigo do FBO da memória do sistema ( Load
  2. Renderizar primitivos que caem nesse bloco
  3. Upload de novo conteúdo do FBO na memória do sistema ( Store )


Deve-se observar que a operação de carregamento pode ser considerada como uma superposição adicional da "textura de tela cheia" sem compactação. Se possível, evite esta operação, ou seja, Não permita que o FBO alterne . Se todo o seu conteúdo for limpo antes da renderização no FBO , a operação Load não será executada. No entanto, para enviar o sinal correto ao motorista, os parâmetros dessa limpeza devem atender a certos critérios:

  1. Deve ser desativado Scissor Rect
  2. A gravação em todos os canais de cores e alfa deve ser permitida.

Para impedir a operação de carregamento do buffer de profundidade e do estêncil, eles também precisam ser limpos antes do início da renderização.

Também é possível evitar a operação de armazenamento para o buffer de profundidade / estêncil. Afinal, o conteúdo desses buffers não é exibido de forma alguma na tela. Antes da operação glSwapBuffers , você pode chamar glDiscardFramebufferEXT ou 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);

Existem cenários de renderização nos quais não é necessária a colocação de buffers de profundidade / estêncil, bem como de MSAA na memória do sistema. Por exemplo, se a renderização no FBO com o buffer de profundidade for contínua e as informações de profundidade do quadro anterior não forem usadas, o buffer de profundidade não precisará ser carregado na memória do bloco antes do início da renderização ou descarregado após a conclusão da renderização. Portanto, a memória do sistema não pode ser alocada no buffer de profundidade. APIs gráficas modernas, como Vulkan e Metal, permitem definir explicitamente o modo de memória para suas contrapartes FBO  ( MTLStorageModeMemoryless in Metal, VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT + VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT no Vulkan ).

Destaca-se a implementação do MSAA em arquiteturas de blocos . O buffer de alta resolução para o MSAA não deixa a memória do bloco dividindo o FBO em mais blocos. Por exemplo, para o MSAA 2x2, os blocos de 16x16 serão resolvidos como 8x8 durante a operação de Armazenamento , ou seja, No total, será necessário processar 4 vezes mais peças. Porém, a memória adicional para o MSAA não é necessária e, devido à renderização na memória rápida do bloco, não haverá restrições significativas de largura de banda. No entanto, useO MSAA na arquitetura de blocos aumenta a carga no Tiler, o que pode afetar negativamente o desempenho da renderização de cenas com muita geometria.

Resumindo o exposto, apresentamos o esquema desejado para trabalhar com o FBO na arquitetura de blocos:

// 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);

Se você alternar para a renderização auxFBO no meio da formação mainFBO , poderá obter operações desnecessárias de carregamento e armazenamento , o que pode aumentar significativamente o tempo de formação de quadros. Em nossa prática, encontramos uma desaceleração na renderização, mesmo no caso de configurações ociosas da FBO, ou seja, sem a renderização real neles. Devido à arquitetura do mecanismo, nosso circuito antigo era assim:

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

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

Apesar da falta de chamadas gl após a primeira instalação do mainFBO , em alguns dispositivos obtivemos operações extras de Load & Store e pior desempenho.

Para melhorar nossa compreensão das despesas indiretas do uso de FBOs intermediários , medimos a perda de tempo para alternar FBOs de tela cheia usando um teste sintético. A tabela mostra o tempo gasto na operação Store ao alternar o FBO várias vezes em um quadro (o tempo de uma dessas operações é fornecido). Operação de carga ausente devido ao glClear, ou seja, um cenário mais favorável foi medido. A permissão usada no dispositivo contribuiu. Pode corresponder mais ou menos ao poder da GPU instalada. Portanto, esses números dão apenas uma idéia geral de quanto custa a troca de alvos em placas de vídeo móveis de várias gerações.
GPUmilissegundosGPUmilissegundos
Adreno 3205.2.
Adreno 5120,74
PowerVR G62003.3.Adreno 6150,7
Mali-4003.2.Adreno 5300,4
Mali-t7201.9Mali-g510,32
PowerVR SXG 5441.4Mali-t830
0,15

Com base nos dados obtidos, podemos chegar à recomendação de não usar mais de um ou dois comutadores FBO por quadro, pelo menos para placas de vídeo antigas. Se o jogo tiver uma passagem de código separada para dispositivos low-end, é aconselhável não usar a alteração FBO lá. No entanto, no low-end, a questão da redução da resolução geralmente se torna relevante. No Android, você pode diminuir a resolução de renderização sem recorrer ao uso de um FBO intermediário chamando SurfaceHolder.setFixedSize ():

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

Este método não funcionará se o jogo for renderizado através do aplicativo principal do Surface (um esquema típico para trabalhar com o NativeActivity ). Se você usar o Surface principal , poderá definir uma resolução mais baixa chamando a função 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); 
}

Em 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); /* ... */
...

Por fim, mencionamos o conveniente comando ADB para controlar os buffers de superfície selecionados no Android:

adb shell dumpsys surfaceflinger

Você pode obter uma conclusão semelhante que permite estimar o consumo de memória para buffers de superfície:


A captura de tela mostra o sistema destacando 3 buffers para o buffer triplo do jogo GLSurfaceView (destacado em amarelo), bem como 2 buffers para a superfície principal (destacada em vermelho). No caso de renderização através do Surface principal, que é o esquema padrão ao usar o NativeActivity , a alocação de buffers adicionais pode ser evitada. 

É tudo por agora. Nos artigos a seguir, classificaremos as GPUs móveis, bem como analisaremos os métodos para otimizar shaders para elas.

All Articles