Exemplo de BatchRendererGroup: Obtenha alta taxa de quadros mesmo em dispositivos econômicos

Nesta postagem, descrevemos um pequeno exemplo de jogo de tiro que anima e renderiza vários objetos interativos. Muitas demonstrações são feitas apenas para PCs de última geração, mas o objetivo aqui é obter uma alta taxa de quadros em um telefone econômico usando o GLES 3.0. Este exemplo usa o BatchRendererGroup, o compilador Burst e o C# Job System. Ele é executado no Unity 2022.3 e não requer pacotes DOTS de entidades ou entities.graphics.
Vamos começar.
Vamos direto ao assunto da amostra. Essa amostra está sendo executada a 60 fps constantes em um Samsung Galaxy A51 econômico de 2019 (usando uma GPU Mali G72-MP3). A API gráfica está definida como GLES 3.0.
Você pode estudar o código e experimentá-lo em sua plataforma favorita fazendo o download do projeto no GitHub. Você só precisará do Unity 2022.3 padrão.
Nesta postagem, vamos nos concentrar principalmente no BatchRendererGroup e na classe de amostra BRG_Container.cs. Você também pode estudar o código de animação e física nas classes BRG_Background.cs e BRG_Debris.cs.
Vamos explorar o que estamos vendo antes de nos aprofundarmos em como fazer isso.
- O piso do plano de fundo é construído com muitos cubos. Todas as caixas são animadas para se moverem para cima e para baixo.
- A nave principal se move horizontalmente na tela e dispara mísseis contra esferas coloridas. (Você pode atirar mísseis mais rapidamente tocando na tela).
- Quando um míssil sobrevoa o piso, um campo magnético levanta levemente e destaca as células do piso. Ele também lança detritos do solo no ar.
- Quando um míssil atinge uma esfera, ele explode em detritos coloridos.
- Quando os detritos atingem o piso, a célula em colisão no piso pisca em branco. Quanto mais detritos atingem uma célula, mais a cor da célula escurece. Além disso, o peso dos detritos causa marcas no solo.
Tanto as células do piso quanto os detritos são feitos de cubos. Cada cubo tem uma posição e uma cor diferentes. Queremos animar e gerenciar tudo usando a CPU para facilitar as interações entre o piso e os detritos. (Os detritos não são apenas um visual cosmético, portanto, não podem ser feitos apenas com a GPU).
Para a renderização, não estamos criando um GameObject por item para evitar um impacto desnecessário no desempenho em um dispositivo móvel de baixo custo. Em vez disso, estamos usando a recém-introduzida API BatchRendererGroup.
Graphics.DrawMeshInstanced é uma maneira conveniente e rápida de renderizar muitas malhas semelhantes em posições diferentes. No entanto, ele tem as seguintes limitações em comparação com a API BatchRendererGroup:
- Isso requer o fornecimento de uma matriz de memória gerenciada com matrizes, de modo que você pode ter coleta de lixo. Além disso, as matrizes invertidas são computadas pela CPU, mesmo que o sombreador não precise delas (por exemplo, com URP/unlit).
- Se quiser personalizar qualquer propriedade que não seja a matriz obj2world (como ter uma cor por instância), você precisará fornecer seu próprio shader personalizado, seja escrevendo-o do zero ou usando o Shader Graph
- A matriz ou os dados personalizados devem ser carregados na memória da GPU a cada sorteio. Não é possível ter dados persistentes na memória da GPU com Graphics.DrawMeshInstanced. Dependendo do contexto, isso pode ser um grande impacto no desempenho.
O BatchRendererGroup (ou BRG) é uma API que gera eficientemente comandos de desenho a partir do C# e produz chamadas de desenho com instanciamento de GPU. Como ele não usa memória gerenciada, você também pode gerar comandos usando o compilador Burst.

Dica: O pacote entities.graphics é feito para renderizar entidades (pacote ECS) e é construído sobre o BRG. O entities.package faz todo o gerenciamento de memória da GPU e a criação de comandos de desenho otimizados para você. Não estamos usando o ECS nesta amostra, portanto, conduziremos diretamente o BRG.
O BRG usa um layout de dados de GPU específico e uma variante de sombreador dedicada. A variante do sombreador pode buscar dados do buffer constante padrão (UnityPerMaterial) ou de um buffer de GPU grande e personalizado (buffer bruto BRG). Cabe a você gerenciar como armazenar seus dados no buffer bruto, que é um Shader Storage Buffer Object (SSBO, ou buffer de endereço de bytes). O layout de dados padrão do BRG é do tipo estrutura de matrizes (SoA).
Você pode instanciar qualquer propriedade de um material sem precisar criar um shader personalizado. No exemplo, queremos instanciar a matriz obj2world (para posicionar os cubos), a matriz world2obj (para iluminação) e BaseColor por instância de caixa (porque cada célula de piso ou detritos tem sua própria cor).
Todas as outras propriedades são as mesmas para todos os cubos (por exemplo, valor de suavidade), e você pode descrever quais propriedades terão valores personalizados por instância usando metadados.
Os metadados BRG são um valor opcional de 32 bits que você pode definir por propriedade do sombreador. Ele informa ao código do shader como carregar o valor da propriedade da memória da GPU e de onde. Os bits 0 a 30 definem o deslocamento da propriedade dentro do buffer bruto BRG, e o bit 31 informa se o valor da propriedade é o mesmo para todas as instâncias ou se o deslocamento é o início de uma matriz, com um valor por instância.
O significado exato dos metadados BRG também depende do tipo de propriedade do shader. Vamos resumir todas as possibilidades:


Ao contrário do Graphics.DrawMeshInstanced, o BRG usa um buffer de memória persistente da GPU. Digamos que você tenha 10 posições e cores de cubo no buffer bruto, mas somente os cubos 0, 3 e 7 estão visíveis. Você deseja desenhar apenas três cubos, mas precisa que o sombreador leia corretamente a posição e a cor desses cubos. Para fazer isso, o shader BRG usa uma pequena indireção adicional. Esse buffer de visibilidade é apenas uma matriz de "int" que você preenche ao gerar comandos de desenho.
Neste exemplo, você precisa preencher uma matriz de três ints com {0,3,7} e, em seguida, pode gerar um comando de desenho BRG de três instâncias.

O código do sombreador para buscar a propriedade "baseColor" tem a seguinte aparência:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
Vá além da amostra: Como você pode instanciar qualquer propriedade dos shaders SRP (unlit, simplelit, lit), todas as propriedades de material têm uma ramificação "if metadata&(1<<31". Mesmo que você não precise de um valor de suavidade personalizado por instância, isso tem algum custo de desempenho. No exemplo, queremos apenas instanciar baseColor. Você pode criar um Shader Graph em que somente a cor será definida como BRG instanciável. Portanto, o código gerado tem a indireção de busca de dados pesados somente para a propriedade de cor. O Shader deve ser executado até um pouco mais rápido em uma GPU de baixo custo.
Em nossa amostra de jogo, o piso é composto de 32x100 células, ou 3.200. Cada uma tem uma posição, altura e cor, e as células rolam enquanto a câmera permanece estática. Quando uma linha é rolada para fora da exibição, injetamos uma nova linha de 32 células.

Com 3.200 células em qualquer momento, a seleção não é realmente necessária (todas as células estão sempre dentro da visão da câmera). Para posicionar cada célula, você precisa de uma matriz obj2world por célula, a matriz invert para iluminação e uma cor. Para renderizar o piso completo, usaremos um único comando de desenho BRG.

Os detritos da amostra são compostos de pequenos cubos, cada um com uma posição, cor e rotação em seu eixo vertical. Isso é muito semelhante às células do assoalho. Para fazer isso, criamos o BRG_Container.cs. A classe gerencia um objeto BRG para renderizar células de piso ou detritos de explosão. Toda a animação física e a interação são feitas com código C# usando o BRG_Debris.cs.
Ao contrário das células de piso, a quantidade de detritos varia ao longo da estrutura. Na inicialização, você especifica o número máximo de itens para BRG_Container. Em nossa amostra, são 16.384 para detritos (cada explosão consiste em 1.024 cubos de detritos) e usamos trabalhos assíncronos para animar os detritos em um campo de gravidade. Quando os detritos atingem uma célula de piso, eles interagem cavando o solo.
Para otimizar o armazenamento da memória da GPU e a largura de banda, o BRG usa um float3x4 para armazenar uma matriz em vez de float4x4. Lembre-se de que uma matriz BRG no buffer bruto tem 48 bytes, não 64.

O buffer bruto terá a seguinte aparência:

Dica: Os dados brutos do buffer de detritos são semelhantes aos dados do piso, pois também usam três propriedades personalizadas (obj2world, world2obj e color). O número máximo de itens é 16.384 para detritos, o que significa um buffer bruto de 112x16.384 bytes, ou 1,75 MiB. Nem todos os detritos são renderizados na maior parte do tempo, dependendo do número de cubos de detritos existentes em um determinado momento.
Temos um GraphicsBuffer de GPU de 358.400 bytes. Como a animação é feita com a CPU, também alocamos um buffer semelhante na memória do sistema (a CPU pode processar dados em velocidade máxima na memória do sistema). Vamos chamar esse segundo buffer de "cópia sombra" da memória da GPU. O código C# animará as células do piso, usando o pecado e os detritos da cópia de sombra. Quando a animação é concluída, carregamos o buffer de cópia de sombra para a GPU usando a API GraphicsBuffer.SetData.
Vá além da amostra: A otimização da renderização da GPU geralmente significa otimizar a quantidade de dados. Em nossa amostra, usamos shaders SRP padrão e de estoque. Por isso, empregamos três float4 para a matriz e um float4 para a cor. Você pode ir além, escrevendo um sombreador personalizado para reduzir o tamanho dos dados, ou pode usar um valor de altura de célula de piso de 32 bits.
Se quiser continuar, use o índice da célula para calcular sua posição mundial e, em seguida, calcule a matriz e inverta a matriz no shader. Por fim, use um número inteiro de 32 bits para armazenar a cor. No final, carregue 8 bytes por item em vez de 112. Isso resulta em um aumento de velocidade de 14 vezes durante o upload de dados da GPU. Isso implicaria reescrever o código de busca do shader.
Qualquer comando de desenho BRG precisa de um MeshID, MaterialID e BatchID. Os dois primeiros são fáceis de entender, mas o BatchID é mais sutil. Pense no BatchID como "tipo de lote". Para renderizar o piso, é necessário registrar um tipo de lote, definido da seguinte forma:
1. A propriedade "unity_ObjectToWorld" é uma matriz que começa no deslocamento 0 do buffer bruto BRG
2. A propriedade "unity_WorldToObject" é uma matriz que começa no deslocamento 153.600
3. A propriedade "_BaseColor" é uma matriz, começando no deslocamento 307.200
O código para registrar esse tipo de lote no momento da criação será semelhante a este:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
Obtemos o m_batchId no momento da criação e podemos usá-lo para cada comando de desenho BRG (para que o sombreador saiba exatamente como buscar dados para esse tipo de lote).
Dica: BatchRendererGroup.AddBatch não é um comando de renderização. É usado para registrar um tipo de lote, para futuros comandos de renderização.
Até agora, podemos animar as células do piso, carregar o buffer de memória do sistema de cópia de sombra para a GPU e renderizar todas as células usando um único DrawCommand de 3.200 instâncias.
Isso funcionará na maioria das plataformas: DirectX, Vulkan, Metal e vários consoles de jogos, mas não no GLES. O problema é que a maioria dos dispositivos GLES 3.0 não pode acessar o SSBO durante o estágio do vértice (ou seja, o valor GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS é 0). Portanto, quando a API de gráficos estiver definida como GLES, o BRG usará um buffer constante, ou UBO, para armazenar os dados brutos.
Isso adiciona restrições: Um buffer constante pode ter qualquer tamanho, mas apenas uma pequena parte dele (uma janela) fica visível em um determinado momento quando o shader está em execução. O tamanho da janela depende do hardware e do driver, mas um valor amplamente aceito é 16 KiB.
Dica: No modo UBO, você deve sempre usar a API BatchRendererGroup.GetConstantBufferMaxWindowSize() para obter o tamanho correto da janela BRG.
Vamos ver como nosso código muda se quisermos executar no GLES. Para células de piso, a quantidade total de dados é de 350 KiB. Não podemos fazer um único DrawInstanced(3,200) porque o shader não conseguirá ver 350 KiB de uma só vez. Portanto, temos que dividir os dados dentro do UBO para maximizar a quantidade de instâncias por sorteio, cabendo em um bloco de 16 KiB. Uma célula de piso tem 112 bytes (duas matrizes e uma cor), portanto, você pode colocar 16.384 dividido por 112, ou 146 instâncias em um bloco de 16 KiB. Para renderizar 3.200 instâncias, precisaremos emitir 21 DrawInstanced(146) e um último DrawInstanced(134).
Agora, o UBO de 350 KiB será dividido em 22 blocos de janelas de 16 KiB cada, da seguinte forma:

Dica: No modo UBO, cada deslocamento de janela deve ser alinhado ao BatchRendererGroup.GetConstantBufferOffsetAlignment(). Os valores típicos de alinhamento variam de 4 a 256 bytes.
No GLES, por causa do UBO e das janelas de 16 KiB, é necessário registrar 22 BatchID para armazenar os offsets de cada janela. O código de inicialização precisa de um loop:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
Dica: Para oferecer suporte ao GLES (UBO) e a outras APIs gráficas (SSBO) na amostra do jogo, o BRG_Container.cs define algumas variáveis no momento da inicialização. No modo SSBO, m_windowCount é 1 e m_alignedGPUWindowSize é o tamanho total do buffer. No modo UBO, m_alignedGPUWindowSize é 16 KiB e m_windowCount contém o número de blocos de 16 KiB. (O valor de 16 KiB é para facilitar a leitura. Use a API GetConstantBufferMaxWindowSize() para obter o valor correto).
Depois que a CPU atualizar todas as matrizes e cores na memória do sistema, você poderá fazer upload dos dados para a GPU. Isso é feito com a função BRG_Container.UploadGpuData. Devido ao modelo de dados SoA, não é possível fazer upload de um único bloco de memória. Para detritos, o buffer é de 16.384 itens. No modo GLES, isso significa 113 janelas de 16 KiB cada, se houver 16.384 detritos na tela.
Mas e se apenas 5.300 cubos de detritos estiverem em um determinado quadro? Como você tem 146 itens por janela, isso significa que as primeiras 36 janelas consecutivas de 16 KiB devem ser carregadas para que você possa usar um único SetData (36x16 KiB). Na última janela, apenas 44 cubos de detritos devem ser exibidos. Para carregar 44 matrizes, inverta matrizes e cores e use três comandos SetData. No final, quatro comandos SetData devem ser emitidos.

Dica: Mesmo no modo SSBO, se o número de itens for menor que o máximo (por exemplo, 5.300 fragmentos em um máximo de 16.384), serão necessários três comandos SetData. Você pode dar uma olhada em BRG_Container.UploadGpuData(int instanceCount) para obter detalhes de implementação.
O principal ponto de entrada do BRG é a função de retorno de chamada de seleção que você fornece no momento da criação. O protótipo tem a seguinte aparência:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
Seu código nesse retorno de chamada é responsável por duas coisas:
1. Para gerar todos os comandos de desenho na estrutura BatchCullingOut de saída
2. Para usar (ou não) as informações fornecidas na estrutura somente leitura BatchCullingContext em seu próprio código de seleção
Observação: A chamada de retorno retorna um JobHandle, caso você queira iniciar um trabalho assíncrono para realizar essas operações. O mecanismo usará isso para sincronizar no momento em que o resultado for necessário, para que seu código de geração de comandos não bloqueie o thread principal.
O BatchCullingContext contém informações como matriz da câmera, planos de frustum da câmera etc. Basicamente, todos os dados de que você precisa para selecionar e gerar menos comandos de desenho. Na amostra, todos os objetos cabem na visualização da câmera (células do piso e detritos), portanto, não há necessidade de usar o código de seleção.
A estrutura BatchCullingOutputDrawCommands contém vários dados, inclusive matrizes. É responsabilidade do usuário alocar memória nativa para essas matrizes. O mecanismo é responsável por liberar essa memória depois que os dados forem consumidos (você está alocando, a Unity é responsável por liberar). A alocação de memória deve ser do tipo Allocator.TempJob.
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
O primeiro array que você deve alocar é o array de visibilidade int. Na amostra, como presumimos que tudo está visível, apenas preenchemos a matriz int de visibilidade com valores incrementais, como {0,1,2,3,4,...}.
Um comando de desenho BRG é quase uma chamada DrawInstanced da GPU. A matriz mais importante a ser alocada e preenchida é a BatchDrawCommand. Digamos que haja 4.737 cubos de detritos no quadro atual.
m_maxInstancePerWindow é 146 no modo GLES. Você pode calcular a quantidade de comandos de desenho e alocar o buffer usando o valor do teto de m_instanceCount dividido por m_maxInstancePerWindow:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
Para evitar a duplicação de parâmetros semelhantes em vários comandos de desenho, o BatchCullingOutputDrawCommands tem uma matriz de BatchDrawRange struct. Você pode configurar vários parâmetros em BatchDrawRange.filterSettings, como renderingLayerMask, receber sinalizadores de sombra, etc. Como todos os comandos de desenho compartilharão as mesmas configurações de renderização, você pode alocar uma única estrutura DrawCommandRange que será aplicada a partir do comando de desenho 0 e conterá todos os comandos drawCommandCount.
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
Em seguida, preencha os comandos de desenho. Cada BatchDrawCommand contém um meshID, batchID (para saber como usar os metadados) e materialID. Ele também contém o deslocamento inicial no buffer da matriz int de visibilidade. Como não precisamos de nenhuma seleção de frustum em nosso contexto, preenchemos a matriz de visibilidade com {0,1,2,3,...}. Então, todos os comandos de desenho se referirão à mesma indireção {0,1,2,3,...}, de modo que cada BatchDrawCommand usará 0 como deslocamento inicial da matriz de visibilidade:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
A condução direta do BatchRendererGroup requer algum trabalho. No entanto, ele funciona imediatamente, sem a necessidade de shaders personalizados ou pacotes adicionais. Em algumas situações, como a necessidade de renderizar vários objetos simulados pela CPU com propriedades instanciadas personalizadas, o BatchRendererGroup é seu melhor amigo.
Você pode fazer o download do projeto neste repositório.
Você também pode visitar os fóruns para discutir detalhes adicionais sobre como usamos o sistema de trabalho C# e o compilador Burst para lidar com todas as animações e interações em velocidade máxima, mesmo em uma CPU de baixo custo.
