Des millions de sprites à plus de 120 images par seconde

image

Si vous vous promenez sur le forum DOTS, vous pouvez y trouver des articles similaires sur la façon dont l'auteur a écrit une bibliothèque capable de rendre un million de sprites animés et n'obtient toujours que 60 images par seconde. J'ai créé mon propre rendu de sprite DOTS , ce qui est assez bon pour notre jeu , mais il n'est pas capable de faire face à un million. J'étais curieux.

J'ai donc bifurqué le référentiel et décidé de vérifier s'il peut être utilisé dans Academia. J'ai un peu expérimenté avec lui, j'ai regardé comment il rend un sprite, cent, puis des milliers. Il s'est avéré qu'il n'était pas tout à fait prêt à être utilisé dans notre jeu. Il manque certains aspects, par exemple le tri des sprites de l'arrière vers l'avant. J'ai essayé d'écrire un hack de cette fonction. Quand j'ai lu le code, j'ai réalisé qu'il pourrait être utile d'écrire une toute nouvelle bibliothèque que nous pouvons utiliser. J'avais juste besoin de comprendre comment cela rend les sprites, mais j'ai déjà compris le principe.

Les bases


Si je veux recréer cette technique de rendu, je dois faire la chose la plus simple: rendre un sprite séparé. La bibliothèque utilise ComputeBuffers. Ils doivent transférer le calcul vers le GPU à l'aide de shaders de calcul. Je ne savais pas ce qui pouvait être utilisé dans un shader ordinaire qui rend quelque chose à l'écran. Vous pouvez les percevoir comme des tableaux de nombres qui peuvent être attribués aux matériaux, après quoi le shader accède à ces matériaux. Par conséquent, vous pouvez transférer des données telles que la position, la rotation, l'échelle, les coordonnées uv, les couleurs - tout ce que vous voulez. Ci-dessous est un shader modifié basé sur cette bibliothèque géniale:

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

Les variables variables transformBuffer, uvBuffer et colorsBuffer sont des «tableaux» que nous définissons dans le code à l'aide de ComputeBuffers. C'est tout ce dont nous avons besoin (pour l'instant) pour rendre le sprite. Voici le script MonoBehaviour pour rendre un sprite unique:

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

Prenons ce code dans l'ordre. Pour le matériau, nous devons créer un nouveau matériau, puis définir le shader décrit ci-dessus pour lui. Attribuez-lui une feuille de texture / sprite. J'utilise une feuille de sprite de la bibliothèque, qui est une icône emoji de sprite 4x4.


Le maillage ici est le maillage créé par CreateQuad (). Ce n'est qu'un quadrilatère composé de deux triangles. Viennent ensuite les trois variables ComputeBuffer, dont nous définirons plus tard le matériau. Je les ai nommés de la même manière que les variables StructuredBuffer dans le shader. Ce n'est pas nécessaire, mais c'est plus pratique.

Les variables args et argsBuffer seront utilisées pour appeler Graphics.DrawMeshInstancedIndirect (). La documentation est ici . Une fonction nécessite un tampon avec cinq valeurs uint. Dans notre cas, seuls les deux premiers sont importants. Le premier est le nombre d'indices, et pour notre quadrilatère il est 6. Le second est le nombre de fois que le quadrilatère sera rendu, c'est juste 1. Je le représente également comme la valeur maximale utilisée par le shader pour indexer StructuredBuffer. Comme ça:

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

La méthode Awake () prépare simplement ComputeBuffers pour assigner du matériel. Nous rendons l'image-objet au point (0, 0) avec une échelle de 0,2f et sans rotation. Pour les UV, nous utilisons le sprite dans le coin inférieur gauche (kiss emoji). Ensuite, nous attribuons une couleur blanche. Le tableau args est défini sur argsBuffer.

Dans Update (), nous appelons simplement Graphics.DrawMeshInstancedIndirect (). (Je ne comprends pas encore très bien comment utiliser BOUNDS ici et je viens de le copier depuis la bibliothèque.)

Les dernières étapes seront de préparer une scène avec une caméra orthogonale. Créez un autre GameObject et ajoutez le composant ComputeBufferBasic. Fixons-lui du matériel en utilisant le shader qui vient d'être montré. Au démarrage, nous obtenons ce qui suit:


Oh oui! Un sprite rendu à l'aide de ComputeBuffer.

Si vous pouvez en faire un, vous pouvez faire beaucoup


Maintenant que nous avons appris à rendre un sprite à l'aide de ComputeBuffers, nous pouvons dessiner beaucoup. Voici un autre script que j'ai créé qui a un paramètre de quantité et rend le nombre spécifié de sprites avec une position, une échelle, une rotation et une couleur aléatoires:

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

Il n'y a pratiquement aucun changement par rapport au rendu d'un seul sprite. La différence est que nous préparons maintenant des tableaux avec un contenu X spécifié par le nombre de variables sérialisées . Nous avons également défini le deuxième nombre dans le tableau args, en le définissant sur count .

En utilisant ce script, nous pouvons définir le nombre de n'importe quelle valeur, et il générera le nombre spécifié de sprites, mais il les rendra en un seul appel de tirage.


Voici 10 000 sprites aléatoires.

Pourquoi les variables sérialisées minScale et maxScale? Lorsque j'ai testé le code avec 600 000 sprites, j'ai remarqué que la vitesse était tombée en dessous de 60fps. Si la bibliothèque source est capable d'un million, alors pourquoi ce code échoue-t-il?


C'est 600 000 sprites. Ça marche lentement.

J'ai suggéré que cela était peut-être dû à un nouveau dessin. J'ai donc créé des paramètres sérialisés minScale et maxScale et défini de petits nombres comme 0,01 et 0,02. Et c'est seulement alors que j'ai pu recréer un million de sprites à plus de 60 images par seconde (à en juger par le profileur de l'éditeur). Peut-être que le code est capable de plus, mais qui a besoin d'un million de sprites? Dans notre jeu, pas un quart de ce nombre n'est requis.


Un million de petits sprites.

Profiler


Donc, je voulais voir comment ce code fonctionne dans une version de test. Caractéristiques de ma voiture: 3,7 GHz (4 cœurs), 16 Go de RAM, Radeon RX 460. Voici ce que j'ai obtenu:


Comme vous pouvez le voir, tout est assez rapide. L'appel à Graphics.DrawMeshInstancedIndirect () affiche 0 ms. Bien que je ne sois pas si sûr de m'inquiéter de Gfx.PresentFrame.


Pas si vite


Bien que le résultat soit impressionnant, dans un vrai jeu, le code sera utilisé d'une manière différente. L'aspect le plus important manquant est le tri des sprites. Et il occupera la plupart des ressources du processeur. De plus, avec les sprites en mouvement, ComputeBuffers devra être mis à jour dans chaque image. Il reste encore beaucoup de travail. Je ne m'attends pas à ce qu'il soit possible d'atteindre un million dans un véritable cadre de travail, mais si j'obtiens quelque chose comme 300 000 en moins de 2 ms, ce sera assez pour moi. DOTS vous aidera certainement, mais c'est un sujet pour un autre article.

All Articles