Millionen Sprites mit mehr als 120 fps

Bild

Wenn Sie im DOTS- Forum herumwandern , können Sie dort ähnliche Beiträge darüber sehen, wie der Autor eine Bibliothek geschrieben hat, die eine Million animierter Sprites rendern kann und trotzdem nur 60 fps erhält. Ich habe meinen eigenen DOTS-Sprite-Renderer erstellt , der für unser Spiel gut genug ist , aber eine Million nicht bewältigen kann. Ich war neugierig.

Also gabelte ich das Repository und beschloss zu prüfen, ob es in der akademischen Welt verwendet werden kann. Ich experimentierte ein wenig mit ihm und beobachtete, wie er ein Sprite rendert, hundert, dann Tausende. Es stellte sich heraus, dass er nicht ganz bereit war, in unserem Spiel eingesetzt zu werden. Es fehlen einige Aspekte, zum Beispiel das Sortieren von Sprites von hinten nach vorne. Ich habe versucht, einen Hack dieser Funktion zu schreiben. Als ich den Code las, wurde mir klar, dass es sich lohnen könnte, eine völlig neue Bibliothek zu schreiben, die wir verwenden können. Ich musste nur herausfinden, wie Sprites gerendert werden, aber ich habe das Prinzip bereits verstanden.

Die Grundlagen


Wenn ich diese Rendering-Technik neu erstellen möchte, muss ich das Einfachste tun: ein separates Sprite rendern. Die Bibliothek verwendet ComputeBuffers. Sie müssen die Berechnung mithilfe von Computer-Shadern auf die GPU übertragen. Ich wusste nicht, was in einem normalen Shader verwendet werden kann, der etwas auf dem Bildschirm darstellt. Sie können sie als Arrays von Zahlen wahrnehmen, die Materialien zugewiesen werden können. Danach greift der Shader auf diese Materialien zu. Daher können Sie Daten wie Position, Drehung, Skalierung, UV-Koordinaten und Farben übertragen - was immer Sie möchten. Unten ist ein Shader, der basierend auf dieser großartigen Bibliothek modifiziert wurde:

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

Die Variablen variable transformBuffer, uvBuffer und coloursBuffer sind „Arrays“, die wir im Code mithilfe von ComputeBuffers definieren. Dies ist alles, was wir (vorerst) brauchen, um das Sprite zu rendern. Hier ist das MonoBehaviour-Skript zum Rendern eines einzelnen Sprites:

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

Nehmen wir diesen Code der Reihe nach. Für das Material müssen wir ein neues Material erstellen und dann den oben beschriebenen Shader dafür einstellen. Weisen Sie ihm ein Textur- / Sprite-Blatt zu. Ich verwende ein Sprite-Blatt aus der Bibliothek, bei dem es sich um ein 4x4-Sprite-Emoji-Symbol handelt.


Das Netz hier ist das von CreateQuad () erstellte Netz. Es ist nur ein Viereck aus zwei Dreiecken. Als nächstes folgen die drei ComputeBuffer-Variablen, für die wir später das Material definieren werden. Ich habe sie genauso benannt wie StructuredBuffer-Variablen im Shader. Dies ist nicht erforderlich, aber bequemer.

Die Variablen args und argsBuffer werden zum Aufrufen von Graphics.DrawMeshInstancedIndirect () verwendet. Die Dokumentation finden Sie hier . Eine Funktion benötigt einen Puffer mit fünf uint-Werten. In unserem Fall sind nur die ersten beiden wichtig. Der erste ist die Anzahl der Indizes und für unser Viereck ist es 6. Der zweite ist die Häufigkeit, mit der das Viereck gerendert wird, dh nur 1. Ich stelle es auch als den Maximalwert dar, den der Shader zum Indizieren von StructuredBuffer verwendet. Ungefähr so:

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

Die Awake () -Methode bereitet ComputeBuffers nur für die Zuweisung von Material vor. Wir rendern das Sprite am Punkt (0, 0) mit einer Skala von 0,2 f und ohne Drehung. Für UV verwenden wir das Sprite in der unteren linken Ecke (Kuss Emoji). Dann weisen wir weiße Farbe zu. Das args-Array ist auf argsBuffer gesetzt.

In Update () rufen wir einfach Graphics.DrawMeshInstancedIndirect () auf. (Ich verstehe noch nicht ganz, wie man BOUNDS hier verwendet und kopiere es einfach aus der Bibliothek.)

Die letzten Schritte bestehen darin, eine Szene mit einer orthogonalen Kamera vorzubereiten. Erstellen Sie ein weiteres GameObject und fügen Sie die ComputeBufferBasic-Komponente hinzu. Stellen wir ihm Material mit dem gerade gezeigten Shader ein. Beim Start erhalten wir Folgendes:


Oh ja! Ein mit ComputeBuffer gerendertes Sprite.

Wenn Sie einen machen können, können Sie viel machen


Nachdem wir gelernt haben, wie man ein Sprite mit ComputeBuffers rendert, können wir viel zeichnen. Hier ist ein weiteres Skript, das ich erstellt habe und das einen Mengenparameter enthält und die angegebene Anzahl von Sprites mit einer zufälligen Position, Skalierung, Drehung und Farbe rendert:

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

Im Vergleich zum Rendern eines einzelnen Sprites gibt es praktisch keine Änderungen. Der Unterschied besteht darin, dass wir jetzt Arrays mit X-Inhalten vorbereiten, die durch die Anzahl der serialisierten Variablen angegeben werden . Wir setzen auch die zweite Zahl im args-Array und setzen sie auf count .

Mit diesem Skript können wir count auf einen beliebigen Wert setzen, und es wird die angegebene Anzahl von Sprites generiert, diese werden jedoch in nur einem Draw-Aufruf gerendert.


Hier sind 10.000 zufällige Sprites.

Warum werden minScale- und maxScale-Variablen serialisiert? Als ich den Code mit 600.000 Sprites testete, bemerkte ich, dass die Geschwindigkeit unter 60 fps fiel. Wenn die Quellbibliothek eine Million kann, warum schlägt dieser Code dann fehl?


Das sind 600.000 Sprites. Es funktioniert langsam.

Ich schlug vor, dass dies möglicherweise auf das Neuzeichnen zurückzuführen ist. Also habe ich serialisierte minScale- und maxScale-Parameter erstellt und kleine Zahlen wie 0,01 und 0,02 festgelegt. Und erst dann konnte ich eine Million Sprites mit mehr als 60 fps neu erstellen (gemessen am Profiler des Editors). Vielleicht kann der Code mehr, aber wer braucht eine Million Sprites? In unserem Spiel wird nicht ein Viertel dieser Zahl benötigt.


Eine Million kleine Sprites.

Profiler


Ich wollte also sehen, wie dieser Code in einem Testbuild funktioniert. Eigenschaften meines Autos: 3,7 GHz (4 Kerne), 16 GB RAM, Radeon RX 460. Folgendes habe ich:


Wie Sie sehen können, ist alles ziemlich schnell. Der Aufruf von Graphics.DrawMeshInstancedIndirect () zeigt 0 ms. Obwohl ich mir nicht sicher bin, ob ich mir Sorgen um Gfx.PresentFrame machen soll.


Nicht so schnell


Obwohl das Ergebnis beeindruckend ist, wird der Code in einem echten Spiel anders verwendet. Der wichtigste fehlende Aspekt ist das Sortieren von Sprites. Und es wird den größten Teil der CPU-Ressourcen beanspruchen. Darüber hinaus müssen ComputeBuffers bei sich bewegenden Sprites in jedem Frame aktualisiert werden. Es bleibt noch viel Arbeit. Ich erwarte nicht, dass es möglich sein wird, eine Million in einem realen Arbeitsrahmen zu erreichen, aber wenn ich in weniger als 2 ms etwa 300.000 erreiche, ist dies für mich völlig ausreichend. DOTS wird definitiv dabei helfen, aber dies ist ein Thema für einen anderen Artikel.

All Articles