Juta sprite di 120+ fps

gambar

Jika Anda berkeliaran di sekitar forum DOTS, Anda dapat menemukan posting serupa di sana tentang bagaimana penulis menulis perpustakaan yang mampu membuat sejuta sprite animasi, dan masih hanya mendapat 60fps. Saya membuat sprite renderer DOTS saya sendiri , yang cukup bagus untuk permainan kami , tetapi tidak dapat mengatasi sejuta. Saya penasaran.

Jadi saya bercabang repositori dan memutuskan untuk memeriksa apakah itu dapat digunakan di Academia. Saya bereksperimen dengannya sedikit, mengamati bagaimana ia menerjemahkan satu sprite, seratus, kemudian ribuan. Ternyata dia tidak cukup siap untuk digunakan dalam permainan kami. Itu tidak memiliki beberapa aspek, misalnya, menyortir sprite dari belakang ke depan. Saya mencoba menulis retasan dari fungsi ini. Ketika saya membaca kode, saya menyadari bahwa mungkin ada baiknya menulis perpustakaan yang sama sekali baru yang dapat kita gunakan. Saya hanya perlu mencari tahu bagaimana itu membuat sprite, tapi saya sudah mengerti prinsipnya.

Dasar


Jika saya ingin membuat ulang teknik rendering ini, maka saya perlu melakukan hal yang paling sederhana: render sprite terpisah. Perpustakaan menggunakan ComputeBuffers. Mereka harus mentransfer perhitungan ke GPU menggunakan shader komputasi. Saya tidak tahu apa yang bisa digunakan dalam shader biasa yang membuat sesuatu di layar. Anda bisa menganggapnya sebagai susunan angka yang dapat ditetapkan untuk material, setelah itu shader mengakses materi ini. Oleh karena itu, Anda dapat mentransfer data seperti posisi, rotasi, skala, koordinat uv, warna - apa pun yang Anda inginkan. Di bawah ini adalah shader yang dimodifikasi berdasarkan perpustakaan yang luar biasa ini:

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

Variabel variabel transformBuffer, uvBuffer, dan colorsBuffer adalah "array" yang kita definisikan dalam kode menggunakan ComputeBuffers. Ini yang kita butuhkan (untuk saat ini) untuk membuat sprite. Berikut ini skrip MonoBehaviour untuk merender sprite tunggal:

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

Mari kita ambil kode ini secara berurutan. Untuk materi, kita perlu membuat materi baru, dan kemudian mengatur shader yang dijelaskan di atas untuk itu. Tetapkan lembar tekstur / sprite. Saya menggunakan lembar sprite dari perpustakaan, yang merupakan ikon emoji sprite 4x4.


Jala di sini adalah jala yang dibuat oleh CreateQuad (). Itu hanya segi empat yang terdiri dari dua segitiga. Berikutnya adalah tiga variabel ComputeBuffer, yang nantinya akan kita tentukan materi untuknya. Saya menamai mereka dengan cara yang sama seperti variabel StructuredBuffer di shader. Ini tidak perlu, tetapi lebih nyaman.

Variabel args dan argsBuffer akan digunakan untuk memanggil Graphics.DrawMeshInstancedIndirect (). Dokumentasinya ada di sini . Suatu fungsi membutuhkan buffer dengan lima nilai uint. Dalam kasus kami, hanya dua yang pertama yang penting. Yang pertama adalah jumlah indeks, dan untuk segi empat kami adalah 6. Yang kedua adalah berapa kali segi empat akan diberikan, yaitu hanya 1. Saya juga menyatakannya sebagai nilai maksimum yang digunakan oleh shader untuk mengindeks StructuredBuffer. Seperti itu:

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

Metode Sedar () hanya menyiapkan ComputeBuffers untuk menetapkan materi. Kami membuat sprite pada titik (0, 0) dengan skala 0,2f dan tanpa rotasi. Untuk UV, kami menggunakan sprite di sudut kiri bawah (ciuman emoji). Lalu kami menetapkan warna putih. Array args diatur ke argsBuffer.

Dalam Pembaruan (), kita cukup memanggil Graphics.DrawMeshInstancedIndirect (). (Saya belum cukup mengerti cara menggunakan BOUNDS di sini dan hanya menyalinnya dari perpustakaan.)

Langkah terakhir adalah mempersiapkan adegan dengan kamera ortogonal. Buat GameObject lain dan tambahkan komponen ComputeBufferBasic. Mari kita atur dia materi menggunakan shader yang baru saja ditampilkan. Saat memulai, kami mendapatkan yang berikut:


Oh ya! Sprite yang diberikan menggunakan ComputeBuffer.

Jika Anda dapat melakukannya, Anda dapat melakukan banyak hal


Sekarang kita telah belajar cara membuat satu sprite menggunakan ComputeBuffers, kita bisa menggambar banyak. Berikut ini adalah skrip lain yang saya buat yang memiliki parameter kuantitas dan merender jumlah sprite yang ditentukan dengan posisi acak, skala, rotasi, dan warna:

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

Praktis tidak ada perubahan dibandingkan dengan rendering satu sprite. Perbedaannya adalah bahwa sekarang kami sedang mempersiapkan array dengan konten X yang ditentukan oleh jumlah variabel serial . Kami juga mengatur angka kedua dalam array args, mengaturnya untuk menghitung .

Dengan menggunakan skrip ini, kita dapat menetapkan jumlah ke nilai apa pun, dan itu akan menghasilkan jumlah sprite yang ditentukan, tetapi itu akan membuatnya dalam satu panggilan undian saja.


Berikut adalah 10.000 sprite acak.

Mengapa variabel serialisasi minScale dan maxScale? Ketika saya menguji kode dengan 600.000 sprite, saya perhatikan bahwa kecepatannya turun di bawah 60fps. Jika pustaka sumber mampu satu juta, lalu mengapa kode ini gagal?


Ini adalah 600.000 sprite. Ini bekerja lambat.

Saya menyarankan bahwa mungkin ini karena menggambar ulang. Jadi saya membuat parameter serialisasi minScale dan maxScale dan mengatur angka kecil seperti 0,01 dan 0,02. Dan hanya pada saat itu saya dapat menciptakan satu juta sprite pada lebih dari 60fps (dilihat dari profiler editor). Mungkin kodenya mampu lebih, tapi siapa yang butuh sejuta sprite? Dalam permainan kami, tidak diperlukan seperempat dari jumlah ini.


Satu juta sprite kecil.

Profiler


Jadi, saya ingin melihat bagaimana kode ini bekerja di test build. Fitur mobil saya: 3,7 GHz (4 core), RAM 16 GB, Radeon RX 460. Inilah yang saya dapatkan:


Seperti yang Anda lihat, semuanya cukup cepat. Panggilan ke Graphics.DrawMeshInstancedIndirect () menunjukkan 0 ms. Meskipun saya tidak begitu yakin apakah perlu khawatir tentang Gfx.PresentFrame.


Tidak secepat itu


Meskipun hasilnya mengesankan, dalam permainan nyata kode akan digunakan dengan cara yang berbeda. Aspek hilang yang paling penting adalah penyortiran sprite. Dan itu akan memakan sebagian besar sumber daya CPU. Selain itu, dengan sprite bergerak, ComputeBuffers perlu diperbarui di setiap frame. Masih banyak pekerjaan yang tersisa. Saya tidak berharap bahwa akan mungkin untuk mencapai satu juta dalam kerangka kerja nyata, tetapi jika saya mencapai sekitar 300.000 dalam waktu kurang dari 2 ms, maka ini akan cukup bagi saya. DOTS pasti akan membantu dengan ini, tetapi ini adalah topik untuk artikel lain.

All Articles