Millones de sprites a más de 120 fps

imagen

Si deambula por el foro DOTS, puede encontrar publicaciones similares allí sobre cómo el autor escribió una biblioteca capaz de generar un millón de sprites animados, y todavía solo obtiene 60 fps. Creé mi propio renderizador de sprites DOTS , que es lo suficientemente bueno para nuestro juego , pero no es capaz de hacer frente a un millón. Estaba curioso.

Entonces bifurqué el repositorio y decidí verificar si se puede usar en la Academia. Experimenté un poco con él, observé cómo representa un sprite, cien y luego miles. Resultó que no estaba listo para usar en nuestro juego. Carece de algunos aspectos, por ejemplo, ordenar sprites de atrás hacia adelante. Traté de escribir un truco de esta función. Cuando leí el código, me di cuenta de que podría valer la pena escribir una biblioteca completamente nueva que podamos usar. Solo necesitaba descubrir cómo representa los sprites, pero ya entendí el principio.

Los basicos


Si quiero recrear esta técnica de renderizado, entonces necesito hacer lo más simple: renderizar un sprite separado. La biblioteca usa ComputeBuffers. Deben transferir el cálculo a la GPU utilizando sombreadores computacionales. No sabía qué podría usarse en un sombreador normal que muestra algo en la pantalla. Puede percibirlos como conjuntos de números que pueden asignarse a materiales, después de lo cual el sombreador accede a estos materiales. Por lo tanto, puede transferir datos como posición, rotación, escala, coordenadas uv, colores, lo que desee. A continuación se muestra un sombreador modificado basado en esta biblioteca impresionante:

  Shader "Instanced/ComputeBufferSprite" {
    Properties {
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
    }
    
    SubShader {
        Tags{
            "Queue"="Transparent"
            "IgnoreProjector"="True"
            "RenderType"="Transparent"
        }
        Cull Back
        Lighting Off
        ZWrite On
        Blend One OneMinusSrcAlpha
        Pass {
            CGPROGRAM
            // Upgrade NOTE: excluded shader from OpenGL ES 2.0 because it uses non-square matrices
            #pragma exclude_renderers gles

            #pragma vertex vert
            #pragma fragment frag
            #pragma target 4.5

            #include "UnityCG.cginc"

            sampler2D _MainTex;

            // xy for position, z for rotation, and w for scale
            StructuredBuffer<float4> transformBuffer;

            // xy is the uv size, zw is the uv offset/coordinate
            StructuredBuffer<float4> uvBuffer; 

	        StructuredBuffer<float4> colorsBuffer;

            struct v2f{
                float4 pos : SV_POSITION;
                float2 uv: TEXCOORD0;
		        fixed4 color : COLOR0;
            };

            float4x4 rotationZMatrix(float zRotRadians) {
                float c = cos(zRotRadians);
                float s = sin(zRotRadians);
                float4x4 ZMatrix  = 
                    float4x4( 
                       c,  -s, 0,  0,a
                       s,  c,  0,  0,
                       0,  0,  1,  0,
                       0,  0,  0,  1);
                return ZMatrix;
            }

            v2f vert (appdata_full v, uint instanceID : SV_InstanceID) {
                float4 transform = transformBuffer[instanceID];
                float4 uv = uvBuffer[instanceID];
                
                //rotate the vertex
                v.vertex = mul(v.vertex - float4(0.5, 0.5, 0,0), rotationZMatrix(transform.z));
                
                //scale it
                float3 worldPosition = float3(transform.x, transform.y, -transform.y/10) + (v.vertex.xyz * transform.w);
                
                v2f o;
                o.pos = UnityObjectToClipPos(float4(worldPosition, 1.0f));
                
                // XY here is the dimension (width, height). 
                // ZW is the offset in the texture (the actual UV coordinates)
                o.uv =  v.texcoord * uv.xy + uv.zw;
                
		        o.color = colorsBuffer[instanceID];
                return o;
            }

            fixed4 frag (v2f i) : SV_Target{
                fixed4 col = tex2D(_MainTex, i.uv) * i.color;
				clip(col.a - 1.0 / 255.0);
                col.rgb *= col.a;

				return col;
            }

            ENDCG
        }
    }
}

Las variables variables transformBuffer, uvBuffer y colorsBuffer son "matrices" que definimos en el código usando ComputeBuffers. Esto es todo lo que necesitamos (por ahora) para renderizar el sprite. Aquí está el script MonoBehaviour para renderizar un solo sprite:

public class ComputeBufferBasic : MonoBehaviour {
    [SerializeField]
    private Material material;

    private Mesh mesh;
    
    // Transform here is a compressed transform information
    // xy is the position, z is rotation, w is the scale
    private ComputeBuffer transformBuffer;
    
    // uvBuffer contains float4 values in which xy is the uv dimension and zw is the texture offset
    private ComputeBuffer uvBuffer;
    private ComputeBuffer colorBuffer;

    private readonly uint[] args = {
        6, 1, 0, 0, 0
    };
    
    private ComputeBuffer argsBuffer;

    private void Awake() {
        this.mesh = CreateQuad();
        
        this.transformBuffer = new ComputeBuffer(1, 16);
        float scale = 0.2f;
        this.transformBuffer.SetData(new float4[]{ new float4(0, 0, 0, scale) });
        int matrixBufferId = Shader.PropertyToID("transformBuffer");
        this.material.SetBuffer(matrixBufferId, this.transformBuffer);
        
        this.uvBuffer = new ComputeBuffer(1, 16);
        this.uvBuffer.SetData(new float4[]{ new float4(0.25f, 0.25f, 0, 0) });
        int uvBufferId = Shader.PropertyToID("uvBuffer");
        this.material.SetBuffer(uvBufferId, this.uvBuffer);
        
        this.colorBuffer = new ComputeBuffer(1, 16);
        this.colorBuffer.SetData(new float4[]{ new float4(1, 1, 1, 1) });
        int colorsBufferId = Shader.PropertyToID("colorsBuffer");
        this.material.SetBuffer(colorsBufferId, this.colorBuffer);

        this.argsBuffer = new ComputeBuffer(1, this.args.Length * sizeof(uint), ComputeBufferType.IndirectArguments);
        this.argsBuffer.SetData(this.args);
    }

    private static readonly Bounds BOUNDS = new Bounds(Vector2.zero, Vector3.one);

    private void Update() {   
        // Draw
        Graphics.DrawMeshInstancedIndirect(this.mesh, 0, this.material, BOUNDS, this.argsBuffer);
    }
    
    // This can be refactored to a utility class
    // Just added it here for the article
    private static Mesh CreateQuad() {
        Mesh mesh = new Mesh();
        Vector3[] vertices = new Vector3[4];
        vertices[0] = new Vector3(0, 0, 0);
        vertices[1] = new Vector3(1, 0, 0);
        vertices[2] = new Vector3(0, 1, 0);
        vertices[3] = new Vector3(1, 1, 0);
        mesh.vertices = vertices;

        int[] tri = new int[6];
        tri[0] = 0;
        tri[1] = 2;
        tri[2] = 1;
        tri[3] = 2;
        tri[4] = 3;
        tri[5] = 1;
        mesh.triangles = tri;

        Vector3[] normals = new Vector3[4];
        normals[0] = -Vector3.forward;
        normals[1] = -Vector3.forward;
        normals[2] = -Vector3.forward;
        normals[3] = -Vector3.forward;
        mesh.normals = normals;

        Vector2[] uv = new Vector2[4];
        uv[0] = new Vector2(0, 0);
        uv[1] = new Vector2(1, 0);
        uv[2] = new Vector2(0, 1);
        uv[3] = new Vector2(1, 1);
        mesh.uv = uv;

        return mesh;
    }
}

Tomemos este código en orden. Para el material, necesitamos crear un nuevo material y luego configurar el sombreador descrito anteriormente. Asigne una textura / hoja de sprite. Utilizo una hoja de sprites de la biblioteca, que es un ícono de emoji de sprites 4x4.


La malla aquí es la malla creada por CreateQuad (). Es solo un cuadrángulo formado por dos triángulos. A continuación están las tres variables ComputeBuffer, para las cuales luego definiremos el material. Los nombré de la misma manera que las variables StructuredBuffer en el sombreador. Esto no es necesario, pero es más conveniente.

Las variables args y argsBuffer se usarán para llamar a Graphics.DrawMeshInstancedIndirect (). La documentación está aquí . Una función requiere un búfer con cinco valores uint. En nuestro caso, solo los dos primeros son importantes. El primero es el número de índices, y para nuestro cuadrilátero es 6. El segundo es el número de veces que se representará el cuadrilátero, eso es solo 1. También lo represento como el valor máximo utilizado por el sombreador para indexar StructuredBuffer. Como eso:

for(int i = 0; i < count; ++i) {
    CallShaderUsingThisIndexForBuffers(i);
}

El método Awake () solo está preparando ComputeBuffers para asignar material. Representamos el sprite en el punto (0, 0) con una escala de 0.2f y sin rotación. Para los rayos UV, usamos el sprite en la esquina inferior izquierda (beso emoji). Luego asignamos color blanco. La matriz args se establece en argsBuffer.

En Update (), simplemente llamamos Graphics.DrawMeshInstancedIndirect (). (Todavía no entiendo cómo usar BOUNDS aquí y solo lo copié de la biblioteca).

Los pasos finales serán preparar una escena con una cámara ortogonal. Cree otro GameObject y agregue el componente ComputeBufferBasic. Vamos a configurar el material con el sombreador que se muestra. Al inicio, obtenemos lo siguiente:


¡Oh si! Un sprite renderizado usando ComputeBuffer.

Si puedes hacer uno, puedes hacer mucho


Ahora que hemos aprendido cómo renderizar un sprite usando ComputeBuffers, podemos dibujar mucho. Aquí hay otro script que creé que tiene un parámetro de cantidad y representa el número especificado de sprites con una posición aleatoria, escala, rotación y color:

public class ComputeBufferMultipleSprites : MonoBehaviour {
    [SerializeField]
    private Material material;
    
    [SerializeField]
    private float minScale = 0.15f;
    
    [SerializeField]
    private float maxScale = 0.2f;  

    [SerializeField]
    private int count;

    private Mesh mesh;
    
    // Matrix here is a compressed transform information
    // xy is the position, z is rotation, w is the scale
    private ComputeBuffer transformBuffer;
    
    // uvBuffer contains float4 values in which xy is the uv dimension and zw is the texture offset
    private ComputeBuffer uvBuffer;
    private ComputeBuffer colorBuffer;

    private uint[] args;
    
    private ComputeBuffer argsBuffer;

    private void Awake() {
        QualitySettings.vSyncCount = 0;
        Application.targetFrameRate = -1;
        
        this.mesh = CreateQuad();
        
        // Prepare values
        float4[] transforms = new float4[this.count];
        float4[] uvs = new float4[this.count];
        float4[] colors = new float4[this.count];

        const float maxRotation = Mathf.PI * 2;
        for (int i = 0; i < this.count; ++i) {
            // transform
            float x = UnityEngine.Random.Range(-8f, 8f);
            float y = UnityEngine.Random.Range(-4.0f, 4.0f);
            float rotation = UnityEngine.Random.Range(0, maxRotation);
            float scale = UnityEngine.Random.Range(this.minScale, this.maxScale);
            transforms[i] = new float4(x, y, rotation, scale);
            
            // UV
            float u = UnityEngine.Random.Range(0, 4) * 0.25f;
            float v = UnityEngine.Random.Range(0, 4) * 0.25f;
            uvs[i] = new float4(0.25f, 0.25f, u, v);
            
            // color
            float r = UnityEngine.Random.Range(0f, 1.0f);
            float g = UnityEngine.Random.Range(0f, 1.0f);
            float b = UnityEngine.Random.Range(0f, 1.0f);
            colors[i] = new float4(r, g, b, 1.0f);
        }
        
        this.transformBuffer = new ComputeBuffer(this.count, 16);
        this.transformBuffer.SetData(transforms);
        int matrixBufferId = Shader.PropertyToID("transformBuffer");
        this.material.SetBuffer(matrixBufferId, this.transformBuffer);
        
        this.uvBuffer = new ComputeBuffer(this.count, 16);
        this.uvBuffer.SetData(uvs);
        int uvBufferId = Shader.PropertyToID("uvBuffer");
        this.material.SetBuffer(uvBufferId, this.uvBuffer);
        
        this.colorBuffer = new ComputeBuffer(this.count, 16);
        this.colorBuffer.SetData(colors);
        int colorsBufferId = Shader.PropertyToID("colorsBuffer");
        this.material.SetBuffer(colorsBufferId, this.colorBuffer);

        this.args = new uint[] {
            6, (uint)this.count, 0, 0, 0
        };
        this.argsBuffer = new ComputeBuffer(1, this.args.Length * sizeof(uint), ComputeBufferType.IndirectArguments);
        this.argsBuffer.SetData(this.args);
    }

    private static readonly Bounds BOUNDS = new Bounds(Vector2.zero, Vector3.one);

    private void Update() {   
        // Draw
        Graphics.DrawMeshInstancedIndirect(this.mesh, 0, this.material, BOUNDS, this.argsBuffer);
    }

    private static Mesh CreateQuad() {
        // Just the same as previous code. I told you this can be refactored.
    }
}

Prácticamente no hay cambios en comparación con la representación de un solo sprite. La diferencia es que ahora estamos preparando matrices con contenido X especificado por el recuento variable serializado . También configuramos el segundo número en la matriz de args, configurándolo para contar .

Con este script, podemos establecer el recuento en cualquier valor, y generará el número especificado de sprites, pero los representará en una sola llamada de sorteo.


Aquí hay 10,000 sprites al azar.

¿Por qué las variables serializadas minScale y maxScale? Cuando probé el código con 600,000 sprites, noté que la velocidad cayó por debajo de 60 fps. Si la biblioteca de origen es capaz de un millón, entonces ¿por qué falla este código?


Esto es 600,000 sprites. Funciona lentamente

Sugerí que tal vez esto se deba a volver a dibujar. Así que hice los parámetros serializados minScale y maxScale y establecí números pequeños como 0.01 y 0.02. Y solo entonces pude recrear un millón de sprites a más de 60 fps (a juzgar por el perfilador del editor). Quizás el código sea capaz de más, pero ¿quién necesita un millón de sprites? En nuestro juego, no se requiere una cuarta parte de este número.


Un millón de pequeños sprites.

Profiler


Entonces, quería ver cómo funciona este código en una compilación de prueba. Características de mi automóvil: 3.7 GHz (4 núcleos), 16 GB de RAM, Radeon RX 460. Esto es lo que obtuve:


Como puede ver, todo es bastante rápido. La llamada a Graphics.DrawMeshInstancedIndirect () muestra 0 ms. Aunque no estoy tan seguro de si preocuparme por Gfx.PresentFrame.


No tan rapido


Aunque el resultado es impresionante, en un juego real el código se usará de manera diferente. El aspecto que falta más importante es la clasificación de los sprites. Y ocupará la mayoría de los recursos de la CPU. Además, con los sprites en movimiento, se necesitará actualizar ComputeBuffers en cada cuadro. Todavía queda mucho trabajo por hacer. No espero que sea posible lograr un millón en un marco de trabajo real, pero si logro algo así como 300,000 en menos de 2 ms, esto será suficiente para mí. DOTS definitivamente ayudará con esto, pero este es un tema para otro artículo.

All Articles