GPU راي البحث عن المفقودين في الوحدة - الجزء 3

[الجزءان الأول والثاني .]


اليوم سنحقق قفزة كبيرة. سنبتعد عن الهياكل الكروية الحصرية والطائرة اللانهائية التي تتبعناها في وقت سابق ، ونضيف مثلثات - الجوهر الكامل لرسومات الكمبيوتر الحديثة ، وهو عنصر تتألف منه جميع العوالم الافتراضية. إذا كنت تريد المتابعة مع ما انتهينا من آخر مرة ، فاستخدم الرمز من الجزء 2 . الكود النهائي لما سنقوم به اليوم متاح هنا . لنبدأ!

مثلثات


المثلث هو مجرد قائمة بثلاثة رؤوس متصلة ، يخزن كل منها موقعه الخاص ، وأحيانًا طبيعي. يحدد ترتيب اجتياز القمم من وجهة نظرك ما الذي ننظر إليه - الجانب الأمامي أو الخلفي للمثلث. تقليديًا ، تُعتبر "الجبهة" هي ترتيب اجتياز عقارب الساعة.

أولاً ، يجب أن نكون قادرين على تحديد ما إذا كان الشعاع يتقاطع مع مثلث ، وإذا كان الأمر كذلك ، في أي نقطة. في عام 1997 ، اقترح السادة توماس أكينين ميلر وبن ترمبور خوارزمية شائعة للغاية (ولكن بالتأكيد ليست الوحيدة ) لتحديد تقاطع الشعاع مع مثلث. يمكنك قراءة المزيد حول هذا الموضوع في مقالهم "تقاطع مثلث شعاع التخزين السريع ، الحد الأدنى" هنا .

يمكن نقل الرمز من المقال بسهولة إلى رمز تظليل HLSL:

static const float EPSILON = 1e-8; bool IntersectTriangle_MT97(Ray ray, float3 vert0, float3 vert1, float3 vert2, inout float t, inout float u, inout float v) { // find vectors for two edges sharing vert0 float3 edge1 = vert1 - vert0; float3 edge2 = vert2 - vert0; // begin calculating determinant - also used to calculate U parameter float3 pvec = cross(ray.direction, edge2); // if determinant is near zero, ray lies in plane of triangle float det = dot(edge1, pvec); // use backface culling if (det < EPSILON) return false; float inv_det = 1.0f / det; // calculate distance from vert0 to ray origin float3 tvec = ray.origin - vert0; // calculate U parameter and test bounds u = dot(tvec, pvec) * inv_det; if (u < 0.0 || u > 1.0f) return false; // prepare to test V parameter float3 qvec = cross(tvec, edge1); // calculate V parameter and test bounds v = dot(ray.direction, qvec) * inv_det; if (v < 0.0 || u + v > 1.0f) return false; // calculate t, ray intersects triangle t = dot(edge2, qvec) * inv_det; return true; } 

لاستخدام هذه الوظيفة ، نحتاج إلى شعاع وثلاثة رؤوس للمثلث. تخبرنا قيمة الإرجاع ما إذا كان المثلث يتقاطع. في حالة التقاطع ، تُحسب ثلاث قيم إضافية: t تصف المسافة على طول الحزمة إلى نقطة التقاطع ، و u / v هما من الإحداثيات الثلاثة ثنائية المركز التي تحدد موقع نقطة التقاطع على المثلث (يمكن حساب الإحداثي الأخير كـ w = 1 - u - v ). إذا لم تكن معتادًا على الإحداثيات ثنائية المركز حتى الآن ، فاقرأ شرحهم الممتاز على Scratchapixel .

دون تأخير كبير ، دعنا نتتبع مثلثًا واحدًا مع الرؤوس الموضحة في الكود! ابحث عن وظيفة Trace في التظليل وأضف جزء الشفرة التالي إليها:

 // Trace single triangle float3 v0 = float3(-150, 0, -150); float3 v1 = float3(150, 0, -150); float3 v2 = float3(0, 150 * sqrt(2), -150); float t, u, v; if (IntersectTriangle_MT97(ray, v0, v1, v2, t, u, v)) { if (t > 0 && t < bestHit.distance) { bestHit.distance = t; bestHit.position = ray.origin + t * ray.direction; bestHit.normal = normalize(cross(v1 - v0, v2 - v0)); bestHit.albedo = 0.00f; bestHit.specular = 0.65f * float3(1, 0.4f, 0.2f); bestHit.smoothness = 0.9f; bestHit.emission = 0.0f; } } 

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


التمرين: حاول حساب الموضع باستخدام إحداثيات متحدة المركز بدلاً من المسافة. إذا قمت بكل شيء بشكل صحيح ، فسيبدو المثلث اللامع تمامًا من قبل.

مثلث تنسجم


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

في رسومات الكمبيوتر ، يتم تعريف الشبكة من خلال العديد من المخازن المؤقتة ، وأهمها المخازن المؤقتة الرأسية والفهرس . المخزن المؤقت لـ vertex عبارة عن قائمة من المتجهات ثلاثية الأبعاد التي تصف موضع كل قمة في مساحة الكائن (وهذا يعني أن هذه القيم لا تحتاج إلى تغيير عند نقل كائن أو تدويره أو تغيير حجمه - يتم تحويلها من مساحة الكائن إلى مساحة العالم أثناء الطيران باستخدام مضاعفة المصفوفة) . المخزن المؤقت الفهرس هو قائمة قيم عدد صحيح التي هي الفهارس التي تشير إلى المخزن المؤقت قمة الرأس. كل ثلاثة فهارس تشكل مثلث. على سبيل المثال ، إذا كان المخزن المؤقت الفهرس له النموذج [0 ، 1 ، 2 ، 0 ، 2 ، 3] ، عنده مثلثان: يتكون المثلث الأول من القمم الأولى والثانية والثالثة في المخزن المؤقت لـ vertex ، ويتكون المثلث الثاني من الأول والثاني والقمم الرابعة. لذلك ، يحدد المخزن المؤقت الفهرس أيضًا ترتيب traversal المذكور أعلاه. بالإضافة إلى المخازن المؤقتة والفهارس قمة الرأس ، قد يكون هناك مخازن مؤقتة إضافية تضيف معلومات أخرى إلى كل قمة. تقوم المخازن المؤقتة الإضافية الأكثر شيوعًا بتخزين الأشياء الطبيعية وتنسيقات النسيج (تسمى texcoords أو UV ببساطة) ، وكذلك ألوان قمة الرأس .

باستخدام GameObjects


بادئ ذي بدء ، نحن بحاجة إلى معرفة أي من GameObjects يجب أن يصبح جزءًا من عملية تتبع الأشعة. الحل الساذج هو ببساطة استخدام FindObjectOfType<MeshRenderer>() ، ولكن القيام بشيء أكثر مرونة وأسرع. لنقم بإضافة مكون RayTracingObject جديد:

 using UnityEngine; [RequireComponent(typeof(MeshRenderer))] [RequireComponent(typeof(MeshFilter))] public class RayTracingObject : MonoBehaviour { private void OnEnable() { RayTracingMaster.RegisterObject(this); } private void OnDisable() { RayTracingMaster.UnregisterObject(this); } } 

تتم إضافة هذا المكون إلى كل كائن نريد استخدامه لتتبع الأشعة ويشارك في تسجيله باستخدام RayTracingMaster . أضف الوظائف التالية إلى المعالج:

 private static bool _meshObjectsNeedRebuilding = false; private static List<RayTracingObject> _rayTracingObjects = new List<RayTracingObject>(); public static void RegisterObject(RayTracingObject obj) { _rayTracingObjects.Add(obj); _meshObjectsNeedRebuilding = true; } public static void UnregisterObject(RayTracingObject obj) { _rayTracingObjects.Remove(obj); _meshObjectsNeedRebuilding = true; } 

كل شيء يسير على ما يرام - الآن نعرف الأشياء التي يجب تتبعها. ولكن بعد ذلك يأتي الجزء الصعب: سنقوم بجمع جميع البيانات من شبكات الوحدة (المصفوفة ، ومخازن القمة الرأسية والفهارس - تذكرها؟) ، وكتابتها على هياكل البيانات الخاصة بنا وتحميلها في GPU بحيث يمكن للتظليل استخدامها. لنبدأ بتحديد بنيات البيانات والمخازن المؤقتة على الجانب C # ، في المعالج:

 struct MeshObject { public Matrix4x4 localToWorldMatrix; public int indices_offset; public int indices_count; } private static List<MeshObject> _meshObjects = new List<MeshObject>(); private static List<Vector3> _vertices = new List<Vector3>(); private static List<int> _indices = new List<int>(); private ComputeBuffer _meshObjectBuffer; private ComputeBuffer _vertexBuffer; private ComputeBuffer _indexBuffer; 

... والآن دعونا نفعل الشيء نفسه في التظليل. هل اعتدت على ذلك؟

 struct MeshObject { float4x4 localToWorldMatrix; int indices_offset; int indices_count; }; StructuredBuffer<MeshObject> _MeshObjects; StructuredBuffer<float3> _Vertices; StructuredBuffer<int> _Indices; 

هياكل البيانات جاهزة ، ويمكننا تعبئتها ببيانات حقيقية. نحن نجمع كل رؤوس كل الشبكات في List<Vector3> كبيرة واحدة List<Vector3> ، وجميع الفهارس في List<int> كبيرة List<int> . لا توجد مشاكل مع القمم ، ولكن يجب تغيير المؤشرات بحيث تستمر في الإشارة إلى القمة الصحيحة في المخزن المؤقت الكبير الخاص بنا. تخيل أننا قمنا بالفعل بإضافة كائنات من 1000 رأس ، والآن نضيف مكعبًا شبكيًا بسيطًا. قد يتكون المثلث الأول من مؤشرات [0 ، 1 ، 2] ، ولكن نظرًا لأن لدينا بالفعل 1000 رأس في المخزن المؤقت ، نحتاج إلى تبديل المؤشرات قبل إضافة رؤوس إلى المكعب. أي أنها ستتحول إلى [1000 ، 1001 ، 1002]. إليك ما يبدو في الكود:

 private void RebuildMeshObjectBuffers() { if (!_meshObjectsNeedRebuilding) { return; } _meshObjectsNeedRebuilding = false; _currentSample = 0; // Clear all lists _meshObjects.Clear(); _vertices.Clear(); _indices.Clear(); // Loop over all objects and gather their data foreach (RayTracingObject obj in _rayTracingObjects) { Mesh mesh = obj.GetComponent<MeshFilter>().sharedMesh; // Add vertex data int firstVertex = _vertices.Count; _vertices.AddRange(mesh.vertices); // Add index data - if the vertex buffer wasn't empty before, the // indices need to be offset int firstIndex = _indices.Count; var indices = mesh.GetIndices(0); _indices.AddRange(indices.Select(index => index + firstVertex)); // Add the object itself _meshObjects.Add(new MeshObject() { localToWorldMatrix = obj.transform.localToWorldMatrix, indices_offset = firstIndex, indices_count = indices.Length }); } CreateComputeBuffer(ref _meshObjectBuffer, _meshObjects, 72); CreateComputeBuffer(ref _vertexBuffer, _vertices, 12); CreateComputeBuffer(ref _indexBuffer, _indices, 4); } 

نحن ندعو RebuildMeshObjectBuffers في وظيفة OnRenderImage ، ولا تنس تحرير المخازن المؤقتة الجديدة في OnDisable . فيما يلي وظيفتان مساعدتان استخدمتهما في الكود أعلاه لتبسيط معالجة المخزن المؤقت قليلاً:

 private static void CreateComputeBuffer<T>(ref ComputeBuffer buffer, List<T> data, int stride) where T : struct { // Do we already have a compute buffer? if (buffer != null) { // If no data or buffer doesn't match the given criteria, release it if (data.Count == 0 || buffer.count != data.Count || buffer.stride != stride) { buffer.Release(); buffer = null; } } if (data.Count != 0) { // If the buffer has been released or wasn't there to // begin with, create it if (buffer == null) { buffer = new ComputeBuffer(data.Count, stride); } // Set data on the buffer buffer.SetData(data); } } private void SetComputeBuffer(string name, ComputeBuffer buffer) { if (buffer != null) { RayTracingShader.SetBuffer(0, name, buffer); } } 

عظيم ، أنشأنا المخازن المؤقتة وأنها مليئة بالبيانات اللازمة! الآن نحن بحاجة فقط إلى الإبلاغ عن هذا إلى تظليل. أضف التعليمات البرمجية التالية إلى SetShaderParameters (وبفضل وظائف المساعد الجديدة ، يمكننا تقليل رمز المخزن المؤقت للكرة):

 SetComputeBuffer("_Spheres", _sphereBuffer); SetComputeBuffer("_MeshObjects", _meshObjectBuffer); SetComputeBuffer("_Vertices", _vertexBuffer); SetComputeBuffer("_Indices", _indexBuffer); 

لذا ، فإن العمل ممل ، ولكن دعونا نرى ما فعلناه للتو: لقد جمعنا جميع البيانات الداخلية للشبكات (المصفوفة والرؤوس والفهارس) ، ووضعناها في بنية مريحة وبسيطة ، ثم أرسلناها إلى وحدة معالجة الرسومات ، والتي تتطلع الآن إلى متى يمكن استخدامها.

تتبع شبكة


دعونا لا نجعله ينتظر. في التظليل ، لدينا بالفعل شفرة التتبع لمثلث فردي ، والشبكة هي في الواقع مجرد الكثير من المثلثات. الجانب الجديد الوحيد هنا هو أننا نستخدم المصفوفة لتحويل الرؤوس من مساحة الكائن إلى مساحة العالم باستخدام دالة mul المدمجة (اختصار للضرب). تحتوي المصفوفة على ترجمة الكائن وتناوبه وحجمه. يبلغ حجمها 4 × 4 ، لذلك بالنسبة للضرب ، نحتاج إلى ناقل 4d. يتم أخذ المكونات الثلاثة الأولى (س ، ص ، ض) من المخزن المؤقت قمة الرأس. قمنا بتعيين المكون الرابع (ث) إلى 1 لأننا نتعامل مع نقطة. إذا كان هذا هو الاتجاه ، فسنكتب 0 فيه لتجاهل كل الترجمات والمقياس في المصفوفة. هل هذا مربك بالنسبة لك؟ ثم اقرأ هذا البرنامج التعليمي ثماني مرات على الأقل. هنا هو رمز التظليل:

 void IntersectMeshObject(Ray ray, inout RayHit bestHit, MeshObject meshObject) { uint offset = meshObject.indices_offset; uint count = offset + meshObject.indices_count; for (uint i = offset; i < count; i += 3) { float3 v0 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i]], 1))).xyz; float3 v1 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i + 1]], 1))).xyz; float3 v2 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i + 2]], 1))).xyz; float t, u, v; if (IntersectTriangle_MT97(ray, v0, v1, v2, t, u, v)) { if (t > 0 && t < bestHit.distance) { bestHit.distance = t; bestHit.position = ray.origin + t * ray.direction; bestHit.normal = normalize(cross(v1 - v0, v2 - v0)); bestHit.albedo = 0.0f; bestHit.specular = 0.65f; bestHit.smoothness = 0.99f; bestHit.emission = 0.0f; } } } } 

نحن على بعد خطوة واحدة فقط من رؤيته في العمل. دعنا نعيد هيكلة وظيفة Trace قليلاً ونضيف تتبعًا لكائنات الشبكة:

 RayHit Trace(Ray ray) { RayHit bestHit = CreateRayHit(); uint count, stride, i; // Trace ground plane IntersectGroundPlane(ray, bestHit); // Trace spheres _Spheres.GetDimensions(count, stride); for (i = 0; i < count; i++) { IntersectSphere(ray, bestHit, _Spheres[i]); } // Trace mesh objects _MeshObjects.GetDimensions(count, stride); for (i = 0; i < count; i++) { IntersectMeshObject(ray, bestHit, _MeshObjects[i]); } return bestHit; } 

النتائج


هذا كل شئ! دعنا نضيف بعض الشبكات البسيطة ( RayTracingObject الوحدة جيدة) ، ومنحهم مكون RayTracingObject ومراقبة السحر. لا تستخدم شبكات مفصلة حتى الآن (أكثر من بضع مئات من المثلثات)! يفتقر تظليلنا إلى التحسين ، وإذا تجاوزته ، فقد يستغرق الأمر ثوانٍ أو حتى دقائق لتتبع عينة واحدة على الأقل لكل بكسل. نتيجة لذلك ، سوف يقوم النظام بإيقاف برنامج التشغيل GPU ، وقد يتعطل محرك Unity ، وسيحتاج الكمبيوتر إلى إعادة التشغيل.


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

من أجل الاهتمام ، قمت بتنزيل Stanford Bunny من أرشيف Morgan McGwire واستخدام المعدِّل العشري لحزمة Blender I خفضت عدد الرؤوس إلى 431. يمكنك تجربة معلمات الإضاءة والمواد ذات الترميز الثابت في وظيفة تظليل IntersectMeshObject . إليك أرنب عازل بظلال ناعمة جميلة وإضاءة عالمية منتشرة قليلاً في Grafitti Shelter :


... وإليك أرنب معدني تحت ضوء الاتجاه القوي لكيب هيل ، يلقي بريق الديسكو على مستوى الأرض:


... وهنا اثنين من الأرانب الصغيرة يختبئون تحت حجر كبير سوزان تحت السماء الزرقاء Kiara 9 Dusk (وصفت مادة بديلة للكائن الثاني ، والتحقق مما إذا كان مؤشر الفهرس هو الصفر):


ما التالي؟


من الرائع رؤية شبكة حقيقية في التتبع الخاص بك للمرة الأولى ، أليس كذلك؟ اليوم ، قمنا بمعالجة بعض البيانات ، واكتشفنا التقاطع باستخدام خوارزمية Meller-Trambor ، وجمعنا كل شيء حتى نتمكن من استخدام محرك GameObjects لمحرك Unity على الفور. بالإضافة إلى ذلك ، رأينا إحدى مزايا تتبع الأشعة: بمجرد إضافة تقاطع جديد إلى الكود ، تبدأ جميع التأثيرات الجميلة (الظلال الناعمة والإضاءة العالمية المنعكسة والمنتشرة وما إلى ذلك) على الفور في العمل.

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

لكننا نحتاج إلى التحرك بالترتيب: علاوة على ذلك ، سنقوم بإزالة المشكلة المتعلقة بالألوان الطبيعية حتى نبدو تنسجم (حتى المنخفضة الكثافة) أكثر نعومة من الآن. سيكون من الجيد أيضًا تحديث المصفوفات تلقائيًا عند نقل الكائنات والإشارة مباشرةً إلى مواد الوحدة ، وليس فقط كتابتها في الكود. هذا هو ما سنفعله في الجزء التالي من سلسلة البرنامج التعليمي. شكرا للقراءة ، وأراك في الجزء 4!

Source: https://habr.com/ru/post/ar450308/


All Articles