Ejemplo de BatchRendererGroup: Alcanza una alta frecuencia de imagen incluso en dispositivos económicos

En este post, describimos una pequeña muestra de juego de disparos que anima y renderiza varios objetos interactivos. Muchas demos están hechas sólo para PC de gama alta, pero el objetivo aquí es conseguir una alta tasa de fotogramas en un teléfono económico utilizando GLES 3.0. Este ejemplo utiliza BatchRendererGroup, el compilador Burst y el sistema de trabajos de C#. Se ejecuta en Unity 2022.3 y no requiere los paquetes entities o entities.graphics DOTS.
Vamos a empezar.
Pasemos directamente a lo que es la muestra. Esta muestra se ejecuta a 60 fps constantes en un Samsung Galaxy A51 económico de 2019 (con una GPU Mali G72-MP3). La API gráfica está configurada como GLES 3.0.
Puedes estudiar el código y probarlo en tu plataforma favorita descargando el proyecto de GitHub. Sólo necesitarás Unity 2022.3 de stock.
En este post nos centraremos principalmente en BatchRendererGroup y en la clase de ejemplo BRG_Container.cs. También puedes estudiar el código de animación y física en las clases BRG_Background.cs y BRG_Debris.cs.
Exploremos lo que vemos antes de profundizar en cómo fabricarlo.
- El suelo del fondo está construido con muchos cubos. Todas las cajas están animadas para moverse hacia arriba y hacia abajo.
- La nave principal se mueve horizontalmente por la pantalla y dispara misiles a esferas de colores. (Puedes disparar misiles más rápido tocando la pantalla).
- Cuando un misil sobrevuela el suelo, un campo magnético levanta ligeramente y resalta las células del suelo. También lanza al aire restos del suelo.
- Cuando un misil impacta contra una esfera, estalla en escombros de colores.
- Cuando los escombros chocan contra el suelo, la célula que choca contra el suelo parpadea en blanco. Cuantos más residuos lleguen a una célula, más se oscurecerá su color. Además, el peso de los escombros provoca hendiduras en el suelo.
Tanto las celdas del suelo como los escombros están formados por cubos. Cada cubo tiene una posición y un color diferentes. Queremos animar y gestionar todo utilizando la CPU para facilitar las interacciones entre el suelo y los escombros. (Los escombros no son sólo un elemento visual cosmético, por lo que no se puede hacer sólo con la GPU).
Para el renderizado, no estamos creando un GameObject por elemento para evitar un golpe de rendimiento innecesario en un dispositivo móvil de gama baja. En su lugar, utilizaremos la nueva API BatchRendererGroup.
Graphics.DrawMeshInstanced es una forma cómoda y rápida de renderizar muchas mallas similares en diferentes posiciones. Sin embargo, tiene las siguientes limitaciones en comparación con la API BatchRendererGroup:
- Requiere proporcionar una matriz de memoria gestionada con matrices, por lo que puede obtener recolección de basura. Además, las matrices invertidas son calculadas por la CPU, incluso si el shader no lo necesita (por ejemplo, con URP/unlit).
- Si quieres personalizar cualquier propiedad que no sea la matriz obj2world (como tener un color por instancia), necesitas proporcionar tu propio shader personalizado, ya sea escribiéndolo desde cero o usando Shader Graph
- Los datos matriciales o personalizados deben cargarse en la memoria de la GPU en cada sorteo. No puedes tener datos persistentes en la memoria de la GPU con Graphics.DrawMeshInstanced. Dependiendo del contexto, esto podría suponer un enorme impacto en el rendimiento.
BatchRendererGroup (o BRG) es una API que genera eficientemente comandos de dibujo a partir de C# y produce llamadas de dibujo que se integran en la GPU. Dado que no utiliza memoria gestionada, también puede generar comandos utilizando el compilador Burst.

Consejo: El paquete entities. graphics está hecho para renderizar entidades (paquete ECS) y está construido sobre BRG. entities.package hace toda la gestión de memoria de la GPU y la creación de comandos de dibujo óptimos por ti. No vamos a usar ECS en esta muestra, así que manejaremos directamente BRG.
BRG utiliza una distribución de datos de GPU específica y una variante de shader dedicada. La variante del sombreador puede obtener datos del búfer constante estándar (UnityPerMaterial) o de un gran búfer GPU personalizado (búfer BRG raw). Depende de ti cómo almacenes los datos en el búfer sin procesar, que es un Shader Storage Buffer Object (SSBO, o búfer de direcciones de bytes). La disposición de datos BRG por defecto es del tipo estructura de matrices (SoA).
Puedes instanciar cualquier propiedad de un material sin tener que crear un shader personalizado. En el ejemplo, queremos instanciar la matriz obj2world (para posicionar los cubos), la matriz world2obj (para la iluminación), y BaseColor por instancia de caja (porque cada celda del suelo o escombro tiene su propio color).
Todas las demás propiedades son las mismas para todos los cubos (por ejemplo, el valor de suavidad), y puede describir qué propiedades tendrán valores personalizados por instancia utilizando metadatos.
Los metadatos BRG son un valor opcional de 32 bits que se puede establecer por propiedad de sombreado. Indica al código del sombreador cómo cargar el valor de la propiedad desde la memoria de la GPU y desde dónde. Los bits 0-30 definen el offset de la propiedad dentro del buffer BRG raw, y el bit 31 indica si el valor de la propiedad es el mismo para todas las instancias o el offset es el principio de un array, con un valor por instancia.
El significado exacto de los metadatos BRG también depende del tipo de propiedad del sombreador. Resumamos todas las posibilidades:


A diferencia de Graphics.DrawMeshInstanced, BRG utiliza un búfer de memoria GPU persistente. Digamos que tienes 10 posiciones de cubos y colores en el buffer raw, pero sólo los cubos 0, 3 y 7 son visibles. Sólo quieres dibujar tres cubos, pero necesitas que el shader lea correctamente la posición y el color de esos cubos. Para ello, el sombreador BRG utiliza una pequeña indirección adicional. Este buffer de visibilidad es simplemente un array de "int" que rellenas cuando generas comandos de dibujo.
En este ejemplo, necesita llenar un array de tres ints con {0,3,7} y puede entonces generar un comando de dibujo BRG de tres instancias.

El código del shader para obtener la propiedad "baseColor" es el siguiente:
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.
Vaya más allá de la muestra: Como se puede instanciar cualquier propiedad de los shaders SRP (unlit, simplelit, lit), todas las propiedades de los materiales tienen una rama "if metadata&(1<<31". Incluso si no necesita un valor de suavidad personalizado por instancia, esto tiene algún coste de rendimiento. En el ejemplo, sólo queremos instanciar baseColor. Puedes crear un Shader Graph donde solo el color será definido como BRG instanciable. Así que el código generado tiene la indirección de búsqueda de datos pesados sólo para la propiedad de color. Shader debería funcionar incluso ligeramente más rápido en una GPU de gama baja.
En nuestro ejemplo de juego, el suelo está formado por 32x100 celdas, es decir, 3.200. Cada una tiene una posición, una altura y un color, y las celdas se desplazan mientras la cámara permanece estática. Cuando una fila se desplaza fuera de la vista, inyectamos una nueva fila de 32 celdas.

Con 3.200 celdas en cualquier momento, la selección no es realmente necesaria (todas las celdas están siempre dentro del campo de visión de la cámara). Para posicionar cada celda, necesitas una matriz obj2world por celda, la matriz invertida para la iluminación y un color. Para renderizar el suelo completo, utilizaremos un único comando de dibujo BRG.

Los restos de la muestra están formados por pequeños cubos, cada uno de los cuales tiene una posición, un color y una rotación sobre su eje vertical. Esto es muy similar a las celdas de suelo. Para ello, creamos BRG_Container.cs. La clase gestiona un objeto BRG para renderizar celdas de suelo o restos de explosiones. Toda la animación e interacción física se realiza con código C# utilizando BRG_Debris.cs.
A diferencia de las celdas de suelo, la cantidad de restos varía a lo largo del marco. En la inicialización, se especifica el número máximo de elementos a BRG_Container. En nuestro ejemplo, son 16.384 para los escombros (cada explosión consta de 1.024 cubos de escombros) y utilizamos trabajos asíncronos para animar los escombros en un campo gravitatorio. Cuando los escombros golpean una célula del suelo, interactúan clavándose en el suelo.
Para optimizar el almacenamiento y el ancho de banda de la memoria de la GPU, BRG utiliza un float3x4 para almacenar una matriz en lugar de float4x4. Tenga en cuenta que una matriz BRG en el búfer sin procesar tiene 48 bytes, no 64.

El búfer sin procesar tendrá este aspecto:

Consejo: Los datos brutos de la memoria intermedia de escombros tienen un aspecto similar a los datos del suelo, ya que también utilizan tres propiedades personalizadas (obj2world, world2obj y color). El número máximo de elementos es de 16.384 para los residuos, lo que significa un búfer en bruto de 112x16.384 bytes, o 1,75 MiB. La mayoría de las veces no se renderizan todos los escombros, dependiendo del número de cubos de escombros existentes en un momento dado.
Tenemos un GPU GraphicsBuffer de 358.400 bytes. Dado que la animación se realiza con la CPU, también asignamos un búfer similar en la memoria del sistema (la CPU puede procesar los datos a toda velocidad en la memoria del sistema). Llamemos a este segundo búfer "copia en la sombra" de la memoria de la GPU. El código C# animará las celdas del suelo, utilizando el pecado, y los escombros de la copia de la sombra. Una vez finalizada la animación, cargamos el búfer de copia de sombras en la GPU mediante la API GraphicsBuffer.SetData.
Vaya más allá de la muestra: Optimizar el renderizado en la GPU suele significar optimizar la cantidad de datos. En nuestra muestra, utilizamos sombreadores SRP estándar y de stock. Por eso empleamos tres float4 para la matriz y un float4 para el color. Podrías ir más allá, escribiendo un shader personalizado para reducir el tamaño de los datos, o podrías utilizar un valor de altura de celda de suelo de 32 bits.
Si desea seguir adelante, utilice el índice de celda para calcular su posición en el mundo, a continuación, calcular la matriz y la matriz de inversión en el shader. Por último, utiliza un entero de 32 bits para almacenar el color. Al final, carga 8 bytes por elemento en lugar de 112. Esto multiplica por 14 la velocidad de carga de datos de la GPU. Implicaría reescribir el código de obtención de shaders.
Cualquier comando de dibujo BRG necesita un MeshID, MaterialID y BatchID. Los dos primeros son fáciles de entender, pero BatchID es más sutil. Piense en BatchID como "tipo de lote". Para renderizar el suelo, es necesario registrar un tipo de lote, definido del siguiente modo:
1. La propiedad "unity_ObjectToWorld" es un array que comienza en el offset 0 del buffer BRG raw
2. La propiedad "unity_WorldToObject" es un array que comienza en el offset 153,600
3. La propiedad "_BaseColor" es una matriz, que comienza en el offset 307.200
El código para registrar este tipo de lote en el momento de la creación tendrá un aspecto similar al siguiente:
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.
Obtenemos el m_batchId en el momento de la creación, y luego podemos utilizarlo para cada comando de dibujo BRG (por lo que el sombreador sabe exactamente cómo obtener datos para ese tipo de lote).
Consejo: BatchRendererGroup.AddBatch no es un comando de renderizado. Se utiliza para registrar una especie de lote, para futuros comandos de renderizado.
Hasta ahora, podemos animar las celdas del suelo, cargar el búfer de memoria del sistema de copia sombra en la GPU y renderizar todas las celdas utilizando un único DrawCommand de 3.200 instancias.
Esto funcionará en la mayoría de las plataformas: DirectX, Vulkan, Metal y varias videoconsolas, pero no en GLES. El problema es que la mayoría de los dispositivos GLES 3.0 no pueden acceder a SSBO durante la etapa de vértices (es decir, el valor GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS es 0). Así, cuando la API de gráficos se establece en GLES, BRG utilizará un búfer constante, o UBO, en su lugar para almacenar los datos en bruto.
Esto añade restricciones: Un búfer constante puede ser de cualquier tamaño, pero sólo una pequeña parte de él (una ventana) es visible en un momento dado cuando el shader se está ejecutando. El tamaño de la ventana depende del hardware y del controlador, pero un valor ampliamente aceptado es 16 KiB.
Consejo: En el modo UBO, siempre se debe utilizar la API BatchRendererGroup.GetConstantBufferMaxWindowSize() para obtener el tamaño de ventana BRG correcto.
Veamos cómo cambia nuestro código si queremos ejecutarlo en GLES. Para las células de suelo, la cantidad total de datos es de 350 KiB. No podemos hacer un solo DrawInstanced(3,200) porque el shader no podrá ver 350 KiB de una vez. Por lo tanto, tenemos que dividir los datos dentro del UBO para maximizar la cantidad de instancias por sorteo, cabiendo en un bloque de 16 KiB. Una celda de suelo tiene 112 bytes (dos matrices y un color), por lo que caben 16.384 dividido por 112, o 146 instancias en un bloque de 16 KiB. Para renderizar 3.200 instancias, necesitaremos emitir 21 DrawInstanced(146) y un último DrawInstanced(134).
Ahora, el UBO de 350KiB se dividirá en 22 bloques de ventana de 16KiB cada uno, así:

Consejo: En el modo UBO, el desplazamiento de cada ventana debe alinearse con BatchRendererGroup.GetConstantBufferOffsetAlignment(). Los valores típicos de alineación oscilan entre 4 y 256 bytes.
En GLES, debido al UBO y a las ventanas de 16 KiB, es necesario registrar 22 BatchID para almacenar los desplazamientos de cada ventana. El código de inicialización necesita entonces un bucle:
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.
Consejo: Para soportar GLES (UBO) y otras API gráficas (SSBO) en el ejemplo de juego, BRG_Container.cs establece algunas variables en el momento de la inicialización. En el modo SSBO, m_windowCount es 1 y m_alignedGPUWindowSize es el tamaño total del búfer. En modo UBO, m_alignedGPUWindowSize es 16 KiB y m_windowCount contiene el número de bloques de 16 KiB. (El valor de 16 KiB es para facilitar la lectura. Utilice la API GetConstantBufferMaxWindowSize() para obtener el valor correcto).
Una vez que la CPU actualiza todas las matrices y colores en la memoria del sistema, puede cargar los datos en la GPU. Esto se hace con la función BRG_Container.UploadGpuData. Debido al modelo de datos SoA, no se puede cargar un único bloque de memoria. En el caso de los residuos, el búfer es de 16.384 elementos. En modo GLES, eso significa 113 ventanas de 16 KiB cada una si hay 16.384 restos en pantalla.
Pero, ¿y si sólo hay 5.300 cubos de escombros en un fotograma determinado? Dado que tiene 146 elementos por ventana, esto significa que las primeras 36 ventanas consecutivas de 16 KiB deben cargarse para que pueda utilizar un único SetData (36x16 KiB). En la última ventana sólo deben aparecer 44 cubos de escombros. Para cargar 44 matrices, invertir matrices y colores y utilizar tres comandos SetData. Al final, deben emitirse cuatro comandos SetData.

Consejo: Incluso en modo SSBO, si el número de elementos es inferior al máximo (por ejemplo, 5.300 restos sobre un máximo de 16.384), se necesitan tres comandos SetData. Puedes echar un vistazo a BRG_Container.UploadGpuData(int instanceCount) para conocer los detalles de la implementación.
El principal punto de entrada de BRG es la función de devolución de llamada de selección que usted proporciona en el momento de la creación. El prototipo parece:
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.
Su código en esta llamada de retorno es responsable de dos cosas:
1. Para generar todos los comandos de dibujo en la estructura de salida BatchCullingOut
2. Para utilizar (o no) la información proporcionada en la estructura de sólo lectura BatchCullingContext dentro de su propio código de culling
Nota: La llamada de retorno devuelve un JobHandle en caso de que quieras lanzar un trabajo asíncrono para realizar estas operaciones. El motor utilizará esto para sincronizar en el punto en que se necesita el resultado, por lo que su código de generación de comandos no bloqueará el hilo principal.
BatchCullingContext contains information like camera matrix, camera frustum plans, etc. Básicamente, todos los datos que necesitas para seleccionar y generar menos comandos de dibujo. En el ejemplo, todos los objetos caben en la vista de la cámara (celdas del suelo y escombros), por lo que no es necesario utilizar código de selección.
La estructura BatchCullingOutputDrawCommands contiene varios datos, incluyendo arrays. Es responsabilidad del usuario asignar memoria nativa para esas matrices. El motor es responsable de liberar esa memoria una vez que los datos han sido consumidos (tú estás asignando, Unity es responsable de liberar). La asignación de memoria debe ser del tipo Allocator.TempJob.
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.
El primer array que debes asignar es el array de visibilidad int. En el ejemplo, como asumimos que todo es visible, simplemente rellenamos la matriz int de visibilidad con valores incrementales, como {0,1,2,3,4,...}.
Un comando de dibujo BRG es casi una llamada GPU DrawInstanced. El array más importante para asignar y llenar es el BatchDrawCommand. Digamos que hay 4.737 cubos de escombros en el marco actual.
m_maxInstancePerWindow es 146 en modo GLES. Puedes calcular la cantidad de comandos de dibujo y asignar el buffer usando el valor máximo de m_instanceCount dividido por m_maxInstancePerWindow:
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.
Para evitar duplicar parámetros similares en varios comandos de dibujo, BatchCullingOutputDrawCommands tiene un array de BatchDrawRange struct. Puede configurar varios parámetros dentro de BatchDrawRange.filterSettings, como renderingLayerMask, recibir banderas de sombra, etc. Como todos los comandos de dibujo compartirán la misma configuración de renderizado, puedes asignar una única estructura DrawCommandRange que se aplicará desde el comando de dibujo 0 y contiene todos los comandos drawCommandCount.
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.
A continuación, rellene los comandos de dibujo. Cada BatchDrawCommand contiene un meshID, batchID (para saber cómo usar los metadatos), y materialID. También contiene el desplazamiento inicial en el búfer de la matriz int de visibilidad. Como no necesitamos ningún frustum culling en nuestro contexto, rellenamos la matriz de visibilidad con {0,1,2,3,...}. Entonces todos los comandos de dibujo se referirán a la misma indirección {0,1,2,3,...} de modo que cada BatchDrawCommand usará 0 como desplazamiento inicial de la matriz de visibilidad.El siguiente código asigna y rellena todos los comandos de dibujo necesarios:
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.
Manejar directamente BatchRendererGroup requiere algo de trabajo. Sin embargo, funciona sin necesidad de shaders personalizados ni paquetes adicionales. En algunas situaciones, como tener que renderizar un montón de objetos simulados por la CPU con propiedades instanciadas personalizadas, BatchRendererGroup es tu mejor amigo.
Puede descargar el proyecto desde este repositorio.
También puedes visitar los foros para discutir sobre detalles adicionales sobre cómo utilizamos el sistema de trabajo C# y el compilador Burst para manejar todas las animaciones e interacciones a toda velocidad, incluso en una CPU de gama baja.
