Milhões de sprites a mais de 120 fps

imagem

Se você perambular pelo fórum DOTS, poderá encontrar posts semelhantes sobre como o autor escreveu uma biblioteca capaz de renderizar um milhão de sprites animados e ainda consegue apenas 60fps. Criei meu próprio renderizador de sprites DOTS , que é bom o suficiente para o nosso jogo , mas não é capaz de lidar com um milhão. Eu estava curioso.

Então eu peguei o repositório e decidi verificar se ele pode ser usado no Academia. Eu experimentei um pouco com ele, observei como ele processa um sprite, cem, depois milhares. Acabou que ele não estava pronto para ser usado em nosso jogo. Faltam alguns aspectos, por exemplo, classificar sprites de trás para frente. Eu tentei escrever um hack desta função. Quando li o código, percebi que poderia valer a pena escrever uma biblioteca completamente nova que possamos usar. Eu só precisava descobrir como ele renderiza sprites, mas eu já entendi o princípio.

O básico


Se eu quiser recriar essa técnica de renderização, preciso fazer a coisa mais simples: renderizar um sprite separado. A biblioteca usa ComputeBuffers. Eles devem transferir a computação para a GPU usando sombreadores computacionais. Não sabia o que poderia ser usado em um sombreador comum que renderize algo na tela. Você pode percebê-los como matrizes de números que podem ser atribuídos aos materiais, após o que o sombreador acessa esses materiais. Portanto, você pode transferir dados como posição, rotação, escala, coordenadas uv, cores - o que quiser. Abaixo está um shader modificado com base nesta impressionante biblioteca:

  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
        }
    }
}

As variáveis ​​variáveis ​​transformBuffer, uvBuffer e colorsBuffer são "matrizes" que definimos no código usando ComputeBuffers. É tudo o que precisamos (por enquanto) para renderizar o sprite. Aqui está o script MonoBehaviour para renderizar um único 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;
    }
}

Vamos levar esse código em ordem. Para o material, precisamos criar um novo material e, em seguida, definir o sombreador descrito acima para ele. Designe uma folha de textura / sprite. Eu uso uma folha de sprite da biblioteca, que é um ícone de emoji de sprite 4x4.


A malha aqui é a malha criada por CreateQuad (). É apenas um quadrângulo formado por dois triângulos. A seguir, estão as três variáveis ​​ComputeBuffer, para as quais definiremos mais tarde o material. Eu os nomeei da mesma maneira que as variáveis ​​StructuredBuffer no shader. Isso não é necessário, mas é mais conveniente.

As variáveis ​​args e argsBuffer serão usadas para chamar Graphics.DrawMeshInstancedIndirect (). A documentação está aqui . Uma função requer um buffer com cinco valores uint. No nosso caso, apenas os dois primeiros são importantes. O primeiro é o número de índices e, para o nosso quadrilátero, é 6. O segundo é o número de vezes que o quadrilátero será renderizado, ou seja, apenas 1. Eu também o represento como o valor máximo usado pelo shader para indexar o StructuredBuffer. Curtiu isso:

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

O método Awake () está apenas preparando ComputeBuffers para atribuir material. Renderizamos o sprite no ponto (0, 0) com uma escala de 0,2f e sem rotação. Para UV, usamos o sprite no canto inferior esquerdo (beijo emoji). Então atribuímos cor branca. A matriz args está definida como argsBuffer.

Em Update (), chamamos simplesmente Graphics.DrawMeshInstancedIndirect (). (Ainda não entendi como usar o BOUNDS aqui e apenas copiei da biblioteca.)

As etapas finais serão preparar uma cena com uma câmera ortogonal. Crie outro GameObject e adicione o componente ComputeBufferBasic. Vamos definir o material para ele usando o shader mostrado. Na inicialização, obtemos o seguinte:


Oh sim! Um sprite renderizado usando o ComputeBuffer.

Se você pode fazer um, você pode fazer muito


Agora que aprendemos como renderizar um sprite usando o ComputeBuffers, podemos desenhar muito. Aqui está outro script que criei que possui um parâmetro de quantidade e renderiza o número especificado de sprites com uma posição, escala, rotação e cor aleatórias:

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.
    }
}

Praticamente não há alterações em comparação com a renderização de um único sprite. A diferença é que agora estamos preparando matrizes com o conteúdo X especificado pela contagem de variáveis ​​serializadas . Também definimos o segundo número na matriz args, configurando-o para contar .

Usando esse script, podemos definir count para qualquer valor e ele gera o número especificado de sprites, mas os renderiza em apenas uma chamada de empate.


Aqui estão 10.000 sprites aleatórios.

Por que as variáveis ​​serializadas minScale e maxScale? Quando testei o código com 600.000 sprites, notei que a velocidade caiu abaixo de 60fps. Se a biblioteca de origem é capaz de um milhão, por que esse código falha?


São 600.000 sprites. Funciona devagar.

Sugeri que talvez isso se deva a um redesenho. Então, criei parâmetros serializados minScale e maxScale e defina números pequenos como 0,01 e 0,02. E só então eu pude recriar um milhão de sprites a mais de 60 qps (a julgar pelo criador de perfil do editor). Talvez o código seja capaz de mais, mas quem precisa de um milhão de sprites? No nosso jogo, nem um quarto desse número é necessário.


Um milhão de pequenos sprites.

analisador


Então, eu queria ver como esse código funciona em uma compilação de teste. Recursos do meu carro: 3,7 GHz (4 núcleos), 16 GB de RAM, Radeon RX 460. Aqui está o que eu tenho:


Como você pode ver, tudo é bem rápido. A chamada para Graphics.DrawMeshInstancedIndirect () mostra 0 ms. Embora eu não tenha tanta certeza se devo me preocupar com Gfx.PresentFrame.


Não tão rápido


Embora o resultado seja impressionante, em um jogo real, o código será usado de uma maneira diferente. O aspecto que falta mais importante é a classificação dos sprites. E isso ocupará a maioria dos recursos da CPU. Além disso, com sprites em movimento, o ComputeBuffers precisará ser atualizado em todos os quadros. Ainda resta muito trabalho. Não espero que seja possível atingir um milhão em uma estrutura de trabalho real, mas se atingir algo em torno de 300.000 em menos de 2 ms, isso será suficiente para mim. O DOTS definitivamente ajudará com isso, mas este é um tópico para outro artigo.

All Articles