مليون عفريت بسرعة 120 إطارًا في الثانية

صورة

إذا كنت تتجول في منتدى DOTS ، يمكنك العثور على مشاركات مماثلة هناك حول كيفية كتابة المؤلف لمكتبة قادرة على تقديم مليون لعبة متحركة ، وما زال يحصل على 60 إطارًا في الثانية فقط. لقد صنعت العارض الخاص بي DOTS sprite ، وهو أمر جيد بما يكفي للعبتنا ، ولكنه غير قادر على التعامل مع مليون. كنت فضوليا.

لذا ، قمت بتخزين المستودع وقررت التحقق مما إذا كان يمكن استخدامه في Academia. لقد جربته قليلاً ، وشاهدت كيف يجعل العفريت واحد ، ومائة ، ثم الآلاف. اتضح أنه لم يكن جاهزًا تمامًا للاستخدام في لعبتنا. يفتقر إلى بعض الجوانب ، على سبيل المثال ، فرز النقوش المتحركة من الخلف إلى الأمام. حاولت كتابة اختراق لهذه الوظيفة. عندما قرأت الشفرة ، أدركت أنه قد يكون من المفيد كتابة مكتبة جديدة تمامًا يمكننا استخدامها. أنا فقط بحاجة لمعرفة كيف يجعل العفاريت ، لكنني فهمت بالفعل المبدأ.

أساسيات


إذا كنت أرغب في إعادة إنشاء أسلوب العرض هذا ، فأنا بحاجة إلى القيام بأبسط شيء: تقديم نقش متحرك منفصل. تستخدم المكتبة ComputeBuffers. يجب عليهم تحويل الحساب إلى GPU باستخدام تظليل حسابي. لم أكن أعرف ما الذي يمكن استخدامه في تظليل منتظم يعرض شيئًا ما على الشاشة. يمكنك إدراكها كمصفوفات للأرقام التي يمكن تعيينها للمواد ، وبعد ذلك يصل التظليل إلى هذه المواد. لذلك ، يمكنك نقل البيانات مثل الموضع ، التدوير ، المقياس ، إحداثيات الأشعة فوق البنفسجية ، الألوان - أي شيء تريده. فيما يلي تعديل تظليل على أساس هذه المكتبة الرائعة:

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

المتغيرات المتغيرات convertBuffer و uvBuffer و coloursBuffer هي "صفائف" نحددها في الكود باستخدام ComputeBuffers. هذا كل ما نحتاجه (الآن) لتقديم العفريت. في ما يلي نص MonoBehaviour لتقديم نقش متحرك واحد:

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

لنأخذ هذا الرمز بالترتيب. بالنسبة للمادة ، نحتاج إلى إنشاء مادة جديدة ، ثم نضع جهاز تظليل الموضح أعلاه لها. تعيين ورقة نسيج / العفريت. أستخدم ورقة الرموز المتحركة من المكتبة ، وهي رمز الرموز التعبيرية 4x4.


الشبكة هنا هي الشبكة التي تم إنشاؤها بواسطة CreateQuad (). إنها مجرد رباعي الزوايا يتكون من مثلثين. فيما يلي متغيرات ComputeBuffer الثلاثة ، والتي سنقوم لاحقًا بتعريف المادة الخاصة بها. قمت بتسميتها بنفس طريقة متغيرات StructuredBuffer في تظليل. هذا ليس ضروريًا ، ولكنه أكثر ملاءمة.

سيتم استخدام المتغيرات argsBuffer و args لاستدعاء Graphics.DrawMeshInstancedIndirect (). الوثائق هنا . تتطلب الوظيفة وجود مخزن مؤقت يحتوي على خمس قيم uint. في حالتنا ، فقط الأولين مهمان. الأول هو عدد المؤشرات ، ولدينا الرباعي هو 6. والثاني هو عدد المرات التي سيتم فيها عرض الرباعي ، وهو 1 فقط. كما أنني أمثلها كحد أقصى للقيمة التي يستخدمها التظليل لفهرسة StructuredBuffer. مثل هذا:

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

طريقة Awake () هي فقط تحضير ComputeBuffers لتعيين مادة. نقدم العفريت عند النقطة (0 ، 0) بمقياس 0.2f وبدون دوران. بالنسبة للأشعة فوق البنفسجية ، نستخدم العفريت في الزاوية اليسرى السفلية (kiss emoji). ثم نخصص اللون الأبيض. تم تعيين مجموعة args إلى argsBuffer.

في Update () ، نسمي ببساطة Graphics.DrawMeshInstancedIndirect (). (لا أفهم تمامًا حتى الآن كيفية استخدام BOUNDS هنا ونسخها للتو من المكتبة.)

وستكون الخطوات النهائية هي إعداد مشهد باستخدام كاميرا متعامدة. إنشاء GameObject آخر وإضافة المكون ComputeBufferBasic. دعنا نضع له المواد باستخدام تظليل الموضح للتو. عند بدء التشغيل ، نحصل على ما يلي:


آه أجل! نقش متحرك باستخدام ComputeBuffer.

إذا كنت تستطيع القيام بأحد ، يمكنك فعل الكثير


الآن بعد أن تعلمنا كيفية تقديم نقش متحرك باستخدام ComputeBuffers ، يمكننا رسم الكثير. في ما يلي نص برمجي آخر أنشأته يحتوي على معلمة كمية ويعرض العدد المحدد من النقوش المتحركة بموضع عشوائي ومقياس وتدوير ولون:

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

لا توجد تغييرات عمليًا مقارنة بعرض سبرايت واحد. الفرق هو أننا الآن نقوم بإعداد صفائف بمحتوى X محدد بواسطة عدد المتغيرات المتسلسلة . قمنا أيضًا بتعيين الرقم الثاني في مصفوفة args ، واضبطه على العد .

باستخدام هذا البرنامج النصي ، يمكننا تعيين العد لأي قيمة ، وسوف يولد العدد المحدد من النقوش المتحركة ، ولكنه سيعرضها في مكالمة سحب واحدة فقط.


هنا 10000 من العفاريت العشوائية.

لماذا المتغيرات المتسلسلة minScale و maxScale؟ عندما اختبرت الكود مع 600،000 نقش سريع ، لاحظت أن السرعة انخفضت إلى أقل من 60 إطارًا في الثانية. إذا كانت مكتبة المصدر قادرة على مليون ، فلماذا يفشل هذا الرمز؟


هذا 600000 عفريت. يعمل ببطء.

اقترحت أنه ربما يكون هذا بسبب إعادة الرسم. لذلك قمت بعمل معلمات متسلسلة minScale و maxScale وقمت بتعيين أرقام صغيرة مثل 0.01 و 0.02. وعندها فقط تمكنت من إعادة إنشاء مليون نقرة سريعة بأكثر من 60 إطارًا في الثانية (بناءً على ملف المحرر). ربما تكون الشفرة قادرة على المزيد ، ولكن من يحتاج إلى مليون عفريت؟ في لعبتنا ، لا يلزم ربع هذا الرقم.


مليون عفاريت صغيرة.

محلل


لذا ، أردت أن أرى كيف يعمل هذا الرمز في إصدار تجريبي. ميزات سيارتي: 3.7 غيغاهرتز (4 نوى) ، 16 غيغابايت من ذاكرة الوصول العشوائي ، Radeon RX 460. إليك ما حصلت عليه:


كما ترون ، كل شيء سريع جدًا. يظهر استدعاء Graphics.DrawMeshInstancedIndirect () 0 مللي ثانية. على الرغم من أنني لست متأكدًا من القلق بشأن Gfx.PresentFrame.


ليس بهذه السرعة


على الرغم من أن النتيجة مثيرة للإعجاب ، إلا أنه في لعبة حقيقية سيتم استخدام الكود بطريقة مختلفة. أهم جانب مفقود هو فرز العفاريت. وسوف تستهلك معظم موارد وحدة المعالجة المركزية. بالإضافة إلى ذلك ، مع نقل النقوش المتحركة ، يجب تحديث ComputeBuffers في كل إطار. لا يزال هناك الكثير من العمل المتبقي. لا أتوقع أنه سيكون من الممكن تحقيق مليون في إطار عمل حقيقي ، ولكن إذا حققت شيئًا مثل 300000 في أقل من 2 مللي ثانية ، فسيكون هذا كافيًا بالنسبة لي. سوف تساعد DOTS بالتأكيد في هذا ، ولكن هذا موضوع لمقال آخر.

All Articles