لمحة عن هذا الدرس التطبيقي حول الترميز
1. مقدمة
ما هي WebGPU؟
WebGPU هي واجهة برمجة تطبيقات حديثة تتيح للمطوّرين الاستفادة من إمكانات وحدة معالجة الرسومات في تطبيقات الويب.
واجهة برمجة التطبيقات الحديثة
قبل WebGPU، كان هناك WebGL الذي يقدّم مجموعة فرعية من ميزات WebGPU. وقد أتاحت هذه التقنية فئة جديدة من محتوى الويب الغني، وتمكّن المطوّرون من إنشاء تطبيقات ومواقع إلكترونية مذهلة باستخدامها. ومع ذلك، كان يستند إلى واجهة برمجة التطبيقات OpenGL ES 2.0 التي تم إصدارها في عام 2007، والتي كانت تستند إلى واجهة برمجة التطبيقات OpenGL الأقدم. وقد تطورت وحدات معالجة الرسومات بشكل كبير خلال هذه الفترة، كما تطورت واجهات برمجة التطبيقات الأصلية المستخدَمة للتفاعل معها، مثل Direct3D 12 وMetal وVulkan.
تتيح WebGPU إمكانات واجهات برمجة التطبيقات الحديثة هذه على منصة الويب. تركّز هذه الواجهة على إتاحة ميزات وحدة معالجة الرسومات بطريقة متوافقة مع جميع الأنظمة الأساسية، مع توفير واجهة برمجة تطبيقات تبدو طبيعية على الويب وأقل تفصيلاً من بعض واجهات برمجة التطبيقات الأصلية التي تستند إليها.
العرض
غالبًا ما ترتبط وحدات معالجة الرسومات بعرض رسومات سريعة ومفصّلة، وWebGPU ليست استثناءً من ذلك. يتضمّن هذا الإصدار الميزات المطلوبة لتوفير العديد من أساليب العرض الأكثر شيوعًا اليوم على كل من وحدات معالجة الرسومات لأجهزة الكمبيوتر والأجهزة الجوّالة، كما يوفّر مسارًا لإضافة ميزات جديدة في المستقبل مع استمرار تطوّر إمكانات الأجهزة.
الحوسبة
بالإضافة إلى العرض، تتيح WebGPU الاستفادة من إمكانات وحدة معالجة الرسومات لتنفيذ مهام عامة ومتوازية للغاية. يمكن استخدام برامج التظليل الحسابية هذه بشكل مستقل، بدون أي مكوّن عرض، أو كجزء متكامل بإحكام من مسار العرض.
في درس اليوم العملي، ستتعلّم كيفية الاستفادة من إمكانات العرض والحساب في WebGPU لإنشاء مشروع تمهيدي بسيط.
ما ستنشئه
في هذا الدرس التطبيقي حول الترميز، ستنشئ لعبة الحياة من ابتكار كونواي باستخدام WebGPU. سيتم إجراء ما يلي في تطبيقك:
- استخدِم إمكانات العرض في WebGPU لرسم رسومات بسيطة ثنائية الأبعاد.
- استخدِم إمكانات الحوسبة في WebGPU لإجراء المحاكاة.
"لعبة الحياة" هي ما يُعرف باسم "الأوتوماتا الخلوية"، حيث تتغيّر حالة شبكة من الخلايا بمرور الوقت استنادًا إلى مجموعة من القواعد. في لعبة "الحياة"، تصبح الخلايا نشطة أو غير نشطة استنادًا إلى عدد الخلايا المجاورة النشطة، ما يؤدي إلى ظهور أنماط مثيرة للاهتمام تتغير أثناء المشاهدة.
ما ستتعلمه
- كيفية إعداد WebGPU وضبط لوحة عرض
- كيفية رسم أشكال هندسية بسيطة ثنائية الأبعاد
- كيفية استخدام برامج تظليل الرؤوس وبرامج تظليل الأجزاء لتعديل ما يتم رسمه
- كيفية استخدام برامج التظليل الحسابية لإجراء محاكاة بسيطة
يركّز هذا الدرس التطبيقي حول الترميز على تقديم المفاهيم الأساسية التي تستند إليها WebGPU. ولا يهدف إلى تقديم مراجعة شاملة لواجهة برمجة التطبيقات، كما أنّه لا يغطّي (أو يتطلّب) مواضيع ذات صلة بشكل متكرّر، مثل رياضيات المصفوفات الثلاثية الأبعاد.
المتطلبات
- إصدار حديث من Chrome (الإصدار 113 أو إصدار أحدث) على ChromeOS أو macOS أو Windows WebGPU هي واجهة برمجة تطبيقات متوافقة مع جميع المتصفحات والأنظمة الأساسية، ولكن لم يتم طرحها في كل مكان بعد.
- معرفة HTML وJavaScript وأدوات مطوّري البرامج في Chrome
لا يُشترط الإلمام بواجهات برمجة تطبيقات الرسومات الأخرى، مثل WebGL أو Metal أو Vulkan أو Direct3D، ولكن إذا كان لديك أي خبرة في استخدامها، من المحتمل أن تلاحظ العديد من أوجه التشابه مع WebGPU التي قد تساعدك في بدء التعلّم بسرعة.
2. طريقة الإعداد
الحصول على الرمز
لا يتضمّن هذا الدرس العملي أي تبعيات، ويرشدك إلى كل خطوة مطلوبة لإنشاء تطبيق WebGPU، لذا لن تحتاج إلى أي رمز للبدء. ومع ذلك، تتوفّر بعض الأمثلة العملية التي يمكن استخدامها كنقاط تحقّق على الرابط https://github.com/GoogleChromeLabs/your-first-webgpu-app-codelab. يمكنك الاطّلاع عليها والاستعانة بها أثناء التقدّم في الدورة التدريبية إذا واجهتك أي صعوبات.
استخدام "وحدة تحكّم المطوّرين"
WebGPU هي واجهة برمجة تطبيقات معقّدة إلى حدّ ما وتتضمّن الكثير من القواعد التي تفرض الاستخدام السليم. والأسوأ من ذلك، بسبب طريقة عمل واجهة برمجة التطبيقات، لا يمكنها إظهار استثناءات JavaScript النموذجية للعديد من الأخطاء، ما يصعّب تحديد مصدر المشكلة بالضبط.
ستواجه مشاكل عند التطوير باستخدام WebGPU، خاصةً إذا كنت مبتدئًا، وهذا أمر طبيعي. يدرك المطوّرون الذين يقفون وراء واجهة برمجة التطبيقات التحديات التي تواجه تطوير وحدة معالجة الرسومات، وقد عملوا جاهدين لضمان أنّه في أي وقت يتسبّب فيه رمز WebGPU في حدوث خطأ، ستتلقّى رسائل مفصّلة ومفيدة جدًا في وحدة تحكّم المطوّرين تساعدك في تحديد المشكلة وحلّها.
من المفيد دائمًا إبقاء وحدة التحكّم مفتوحة أثناء العمل على أي تطبيق ويب، ولكنّ هذا الإجراء مهم بشكل خاص في هذه الحالة.
3. إعداد WebGPU
ابدأ بـ <canvas>
يمكن استخدام WebGPU بدون عرض أي شيء على الشاشة إذا كان كل ما تريده هو استخدامها لإجراء العمليات الحسابية. ولكن إذا أردت عرض أي شيء، كما سنفعل في الدرس العملي، ستحتاج إلى لوحة رسم. لذا، هذا مكان جيد للبدء.
أنشئ مستند HTML جديدًا يتضمّن عنصر <canvas>
واحدًا، بالإضافة إلى علامة <script>
حيث نطلب عنصر اللوحة. (أو استخدِم 00-starter-page.html).
- أنشئ ملف
index.html
بالرمز التالي:
index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WebGPU Life</title>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script type="module">
const canvas = document.querySelector("canvas");
// Your WebGPU code will begin here!
</script>
</body>
</html>
طلب وصلة تحويل وجهاز
يمكنك الآن التعرّف على تفاصيل WebGPU. أولاً، يجب أن تأخذ في الاعتبار أنّ واجهات برمجة التطبيقات، مثل WebGPU، قد تستغرق بعض الوقت للانتشار في منظومة الويب المتكاملة بأكملها. نتيجةً لذلك، تتمثّل الخطوة الأولى الجيدة الاحترازية في التحقّق ممّا إذا كان بإمكان متصفّح المستخدم استخدام WebGPU.
- للتحقّق مما إذا كان العنصر
navigator.gpu
، الذي يعمل كنقطة دخول إلى WebGPU، متوفّرًا، أضِف الرمز التالي:
index.html
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
من المفترض أن تُعلم المستخدم بأنّ WebGPU غير متاحة من خلال إعادة الصفحة إلى وضع لا يستخدم WebGPU. (ربما يمكن استخدام WebGL بدلاً من ذلك؟) ومع ذلك، لأغراض هذا الدرس العملي، ما عليك سوى عرض خطأ لإيقاف تنفيذ الرمز البرمجي.
بعد التأكّد من أنّ المتصفّح يتيح استخدام WebGPU، تتمثّل الخطوة الأولى في إعداد WebGPU لتطبيقك في طلب GPUAdapter
. يمكن اعتبار المحوّل تمثيلاً لقطعة معيّنة من أجهزة وحدة معالجة الرسومات في جهازك.
- للحصول على محوّل، استخدِم طريقة
navigator.gpu.requestAdapter()
. تعرض هذه الدالة وعدًا، لذا من الأنسب استدعاؤها باستخدامawait
.
index.html
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("No appropriate GPUAdapter found.");
}
إذا لم يتم العثور على أي محوّلات مناسبة، قد تكون القيمة المعروضة adapter
هي null
، لذا عليك التعامل مع هذا الاحتمال. قد يحدث ذلك إذا كان متصفّح المستخدم متوافقًا مع WebGPU ولكنّ أجهزة وحدة معالجة الرسومات (GPU) لا تتضمّن جميع الميزات اللازمة لاستخدام WebGPU.
في معظم الأحيان، لا بأس في السماح للمتصفّح باختيار محوّل تلقائي، كما تفعل هنا، ولكن لتلبية الاحتياجات الأكثر تقدّمًا، هناك وسيطات يمكن تمريرها إلى requestAdapter()
لتحديد ما إذا كنت تريد استخدام أجهزة منخفضة الطاقة أو عالية الأداء على الأجهزة التي تتضمّن وحدات معالجة رسومات متعدّدة (مثل بعض أجهزة الكمبيوتر المحمول).
بعد الحصول على محوّل، الخطوة الأخيرة قبل البدء في استخدام وحدة معالجة الرسومات هي طلب GPUDevice. الجهاز هو الواجهة الرئيسية التي يتم من خلالها معظم التفاعلات مع وحدة معالجة الرسومات.
- يمكنك الحصول على الجهاز من خلال الاتصال بالرقم
adapter.requestDevice()
، الذي يعرض أيضًا وعدًا.
index.html
const device = await adapter.requestDevice();
كما هو الحال مع requestAdapter()
، هناك خيارات يمكن تمريرها هنا لاستخدامات أكثر تقدّمًا، مثل تفعيل ميزات أجهزة معيّنة أو طلب حدود أعلى، ولكن لأغراضك، ستكون الإعدادات التلقائية مناسبة تمامًا.
ضبط Canvas
بعد أن أصبح لديك جهاز، هناك خطوة أخرى عليك اتّخاذها إذا كنت تريد استخدامه لعرض أي شيء على الصفحة، وهي ضبط لوحة العرض لاستخدامها مع الجهاز الذي أنشأته للتو.
- لإجراء ذلك، عليك أولاً طلب
GPUCanvasContext
من لوحة العرض عن طريق استدعاءcanvas.getContext("webgpu")
. (هذه هي الدالة نفسها التي تستخدمها لتهيئة سياقات Canvas 2D أو WebGL، باستخدام نوعَي السياق2d
وwebgl
على التوالي). يجب بعد ذلك ربطcontext
الذي يتم عرضه بالجهاز باستخدام الطريقةconfigure()
، كما يلي:
index.html
const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
يمكن تمرير بعض الخيارات هنا، ولكن أهمها هو device
الذي ستستخدم السياق معه وformat
، وهو تنسيق النسيج الذي يجب أن يستخدمه السياق.
النسيج هو العنصر الذي يستخدمه WebGPU لتخزين بيانات الصور، ولكل نسيج تنسيق يتيح لوحدة معالجة الرسومات معرفة كيفية ترتيب هذه البيانات في الذاكرة. لا يتناول هذا الدرس التطبيقي العملي تفاصيل طريقة عمل ذاكرة النسيج. من المهم معرفة أنّ سياق لوحة العرض يوفّر موادًا ليرسمها الرمز البرمجي، ويمكن أن يؤثّر التنسيق الذي تستخدمه في مدى فعالية عرض لوحة العرض لهذه الصور. تؤدي أنواع الأجهزة المختلفة أفضل أداء عند استخدام أشكال مختلفة من النسيج، وإذا لم تستخدم الشكل المفضّل للجهاز، قد يؤدي ذلك إلى حدوث نُسخ إضافية من الذاكرة في الخلفية قبل أن يتم عرض الصورة كجزء من الصفحة.
لحسن الحظ، لن تحتاج إلى القلق بشأن أي من ذلك لأنّ WebGPU يخبرك بالتنسيق الذي يجب استخدامه للوحة العرض. في جميع الحالات تقريبًا، عليك تمرير القيمة التي يتم عرضها من خلال استدعاء navigator.gpu.getPreferredCanvasFormat()
، كما هو موضّح أعلاه.
محو اللوحة
بعد إعداد الجهاز وربطه بلوحة العرض، يمكنك البدء باستخدامه لتغيير محتوى اللوحة. للبدء، امسحها بلون واحد.
لإجراء ذلك أو أي شيء آخر في WebGPU، عليك تقديم بعض الأوامر إلى وحدة معالجة الرسومات لتوجيهها بشأن الإجراءات التي يجب اتّخاذها.
- ولإجراء ذلك، اطلب من الجهاز إنشاء
GPUCommandEncoder
، الذي يوفّر واجهة لتسجيل أوامر وحدة معالجة الرسومات.
index.html
const encoder = device.createCommandEncoder();
إنّ الأوامر التي تريد إرسالها إلى وحدة معالجة الرسومات (GPU) مرتبطة بالعرض (في هذه الحالة، محو لوحة الرسم)، لذا فإنّ الخطوة التالية هي استخدام encoder
لبدء عملية العرض.
تحدث عمليات العرض عندما تتم جميع عمليات الرسم في WebGPU. يبدأ كل منها باستدعاء beginRenderPass()
، الذي يحدّد الأنسجة التي تتلقّى ناتج أي أوامر رسم يتم تنفيذها. يمكن أن توفّر الاستخدامات الأكثر تقدّمًا العديد من الأنسجة، التي تُسمّى المرفقات، لأغراض مختلفة، مثل تخزين عمق الأشكال الهندسية المعروضة أو توفير التنعيم. ومع ذلك، لا تحتاج إلى أكثر من واحد لهذا التطبيق.
- يمكنك الحصول على الملمس من سياق لوحة الرسم الذي أنشأته سابقًا عن طريق استدعاء
context.getCurrentTexture()
، ما يؤدي إلى عرض ملمس بعرض وارتفاع بالبكسل يتطابقان مع السمتَينwidth
وheight
للوحة الرسم والسمةformat
المحدّدة عند استدعاءcontext.configure()
.
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
storeOp: "store",
}]
});
يتم تقديم الملمس كسمة view
ضمن colorAttachment
. تتطلّب عمليات العرض توفير GPUTextureView
بدلاً من GPUTexture
، ما يحدد الأجزاء التي سيتم عرضها من النسيج. لا يهمّ ذلك إلا في حالات الاستخدام الأكثر تقدّمًا، لذا يمكنك هنا استدعاء createView()
بدون وسيطات على الملمس، ما يشير إلى أنّك تريد أن يستخدم تمرير العرض الملمس بأكمله.
عليك أيضًا تحديد ما تريد أن تفعله عملية العرض بالخامة عند بدئها وعند انتهائها:
- تشير القيمة
loadOp
التي تبلغ"clear"
إلى أنّك تريد محو النسيج عند بدء عملية العرض. - تشير القيمة
storeOp
التي تبلغ"store"
إلى أنّه بعد انتهاء عملية العرض، عليك حفظ نتائج أي رسم تم إجراؤه أثناء عملية العرض في النسيج.
بعد بدء عملية عرض الصورة، لا عليك فعل أي شيء. على الأقل في الوقت الحالي. يكفي بدء عملية العرض باستخدام loadOp: "clear"
لمحو عرض النسيج واللوحة.
- أنهِ عملية العرض بإضافة الاستدعاء التالي مباشرةً بعد
beginRenderPass()
:
index.html
pass.end();
من المهم معرفة أنّ إجراء هذه الطلبات لا يؤدي إلى تنفيذ أي إجراءات على وحدة معالجة الرسومات. إنّها تسجّل الأوامر فقط لكي تنفّذها وحدة معالجة الرسومات لاحقًا.
- لإنشاء
GPUCommandBuffer
، اتّصِل بـfinish()
على أداة ترميز الأوامر. مخزن الأوامر المؤقت هو معرّف مبهم للأوامر المسجّلة.
index.html
const commandBuffer = encoder.finish();
- أرسِل مخزن الأوامر المؤقت إلى وحدة معالجة الرسومات (GPU) باستخدام
queue
منGPUDevice
. ينفّذ قائمة الانتظار جميع أوامر وحدة معالجة الرسومات، ما يضمن ترتيب تنفيذها بشكل جيد ومزامنتها بشكل صحيح. تتلقّى طريقةsubmit()
في قائمة الانتظار مصفوفة من مخازن أوامر مؤقتة، ولكن في هذه الحالة لديك مخزن واحد فقط.
index.html
device.queue.submit([commandBuffer]);
بعد إرسال مخزن مؤقت للأوامر، لا يمكن استخدامه مرة أخرى، لذا لا داعي للاحتفاظ به. إذا كنت تريد إرسال المزيد من الأوامر، عليك إنشاء مخزن مؤقت آخر للأوامر. لهذا السبب، من الشائع جدًا أن يتم دمج هاتين الخطوتين في خطوة واحدة، كما هو الحال في الصفحات النموذجية لهذا الدرس التطبيقي حول الترميز:
index.html
// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);
بعد إرسال الأوامر إلى وحدة معالجة الرسومات، اسمح لـ JavaScript بإعادة التحكّم إلى المتصفّح. في هذه المرحلة، يلاحظ المتصفّح أنّك غيّرت الملمس الحالي للسياق ويعدّل لوحة العرض لعرض هذا الملمس كصورة. إذا أردت تعديل محتوى لوحة العرض مرة أخرى بعد ذلك، عليك تسجيل وإرسال مخزن مؤقت جديد للأوامر، واستدعاء context.getCurrentTexture()
مرة أخرى للحصول على نسيج جديد لتمرير العرض.
- إعادة تحميل الصفحة لاحظ أنّ لوحة العرض ممتلئة باللون الأسود. تهانينا! وهذا يعني أنّك أنشأت تطبيقك الأول المتضمّن واجهة برمجة التطبيقات WebGPU بنجاح.
اختَر لونًا.
مع ذلك، لنكُن صادقين، المربّعات السوداء مملّة جدًا. لذا، ننصحك بأخذ بعض الوقت قبل الانتقال إلى القسم التالي لتخصيصه قليلاً.
- في استدعاء
encoder.beginRenderPass()
، أضِف سطرًا جديدًا يتضمّنclearValue
إلىcolorAttachment
، على النحو التالي:
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
storeOp: "store",
}],
});
يُعلِم clearValue
عملية العرض اللون الذي يجب استخدامه عند تنفيذ عملية clear
في بداية عملية العرض. يحتوي القاموس الذي تم تمريره إليه على أربع قيم: r
للون الأحمر وg
للون الأخضر وb
للون الأزرق وa
للون ألفا (الشفافية). يمكن أن تتراوح كل قيمة من 0
إلى 1
، وتصف معًا قيمة قناة الألوان هذه. على سبيل المثال:
{ r: 1, g: 0, b: 0, a: 1 }
هو أحمر ساطع.{ r: 1, g: 0, b: 1, a: 1 }
هو أرجواني ساطع.{ r: 0, g: 0.3, b: 0, a: 1 }
هو أخضر داكن.{ r: 0.5, g: 0.5, b: 0.5, a: 1 }
هو اللون الرمادي المتوسط.{ r: 0, g: 0, b: 0, a: 0 }
هو اللون التلقائي، وهو أسود شفاف.
يستخدم الرمز النموذجي ولقطات الشاشة في هذا الدرس العملي لونًا أزرق داكنًا، ولكن يمكنك اختيار أي لون تريده.
- بعد اختيار اللون، أعِد تحميل الصفحة. من المفترض أن يظهر اللون الذي اخترته في لوحة العرض.
4. رسم الأشكال الهندسية
في نهاية هذا القسم، سيرسم تطبيقك بعض الأشكال الهندسية البسيطة على لوحة الرسم: مربّع ملوّن. يُرجى العلم أنّك ستشعر بأنّها تتطلّب الكثير من العمل للحصول على هذا الناتج البسيط، ولكن ذلك لأنّ WebGPU مصمَّمة لعرض الكثير من الأشكال الهندسية بكفاءة عالية. من الآثار الجانبية لهذه الكفاءة أنّ تنفيذ مهام بسيطة نسبيًا قد يبدو صعبًا بشكل غير معتاد، ولكن هذا هو المتوقّع إذا كنت تستخدم واجهة برمجة تطبيقات مثل WebGPU، لأنّك تريد تنفيذ مهام أكثر تعقيدًا.
فهم طريقة رسم وحدات معالجة الرسومات
قبل إجراء أي تغييرات أخرى على الرمز، من المفيد تقديم نظرة عامة سريعة ومبسّطة وعالية المستوى حول كيفية إنشاء وحدات معالجة الرسومات للأشكال التي تظهر على الشاشة. (يمكنك الانتقال إلى قسم "تحديد الرؤوس" إذا كنت على دراية بأساسيات طريقة عرض الرسومات على وحدة معالجة الرسومات).
على عكس واجهة برمجة التطبيقات مثل Canvas 2D التي تتضمّن الكثير من الأشكال والخيارات الجاهزة للاستخدام، لا تتعامل وحدة معالجة الرسومات إلا مع بضعة أنواع مختلفة من الأشكال (أو العناصر الأساسية كما يُشار إليها في WebGPU): النقاط والخطوط والمثلثات. لأغراض هذا الدرس البرمجي، ستستخدم المثلثات فقط.
تعمل وحدات معالجة الرسومات بشكل حصري تقريبًا مع المثلثات لأنّ المثلثات تتضمّن الكثير من الخصائص الرياضية الرائعة التي تسهّل معالجتها بطريقة يمكن توقّعها وفعّالة. يجب تقسيم كل ما ترسمه باستخدام وحدة معالجة الرسومات إلى مثلثات قبل أن تتمكّن وحدة معالجة الرسومات من رسمه، ويجب تحديد هذه المثلثات من خلال نقاط الزوايا.
يتم تقديم هذه النقاط، أو الرؤوس، من حيث قيم X وY و (بالنسبة إلى المحتوى الثلاثي الأبعاد) قيم Z التي تحدد نقطة في نظام الإحداثيات الديكارتية الذي تحدده WebGPU أو واجهات برمجة التطبيقات المشابهة. يمكن فهم بنية نظام الإحداثيات بسهولة أكبر من خلال التفكير في كيفية ارتباطها بلوحة العرض على صفحتك. بغض النظر عن عرض لوحة العرض أو ارتفاعها، تكون الحافة اليسرى دائمًا عند -1 على المحور X، وتكون الحافة اليمنى دائمًا عند +1 على المحور X. وبالمثل، تكون الحافة السفلية دائمًا -1 على المحور Y، وتكون الحافة العلوية +1 على المحور Y. وهذا يعني أنّ (0, 0) هو دائمًا مركز لوحة الرسم، و(-1, -1) هو دائمًا الزاوية السفلية اليسرى، و (1, 1) هو دائمًا الزاوية العلوية اليمنى. يُعرف ذلك باسم مساحة القطع.
ونادرًا ما يتم تحديد الرؤوس في نظام الإحداثيات هذا في البداية، لذا تعتمد وحدات معالجة الرسومات على برامج صغيرة تُعرف باسم تظليل الرؤوس لإجراء أي عمليات حسابية ضرورية لتحويل الرؤوس إلى مساحة القطع، بالإضافة إلى أي عمليات حسابية أخرى مطلوبة لرسم الرؤوس. على سبيل المثال، قد يطبّق برنامج التظليل بعض التأثيرات المتحركة أو يحسب الاتجاه من الرأس إلى مصدر الضوء. تتم كتابة برامج التظليل هذه من قِبل مطوّر WebGPU، وتوفّر قدرًا كبيرًا من التحكّم في طريقة عمل وحدة معالجة الرسومات.
بعد ذلك، تأخذ وحدة معالجة الرسومات جميع المثلثات المكوّنة من هذه الرؤوس المحوّلة وتحدّد وحدات البكسل اللازمة لرسمها على الشاشة. بعد ذلك، يتم تشغيل برنامج صغير آخر تكتبه يُسمى برنامج تظليل الأجزاء، وهو يحسب اللون الذي يجب أن تكون عليه كل بكسل. يمكن أن تكون هذه العملية بسيطة مثل اللون الأخضر العائد أو معقدة مثل حساب زاوية السطح بالنسبة إلى ضوء الشمس المرتد عن الأسطح القريبة الأخرى، والذي يتم ترشيحه من خلال الضباب، وتعديله حسب مدى معدنية السطح. يمكنك التحكّم في كل ذلك بالكامل، ما قد يكون مفيدًا ومربكًا في الوقت نفسه.
بعد ذلك، يتم تجميع نتائج ألوان وحدات البكسل هذه في نسيج يمكن عرضه على الشاشة.
تحديد الرؤوس
كما ذكرنا سابقًا، يتم عرض محاكاة "لعبة الحياة" على شكل شبكة من الخلايا. يجب أن يوفّر تطبيقك طريقة لعرض الشبكة بشكل مرئي، مع التمييز بين الخلايا النشطة والخلايا غير النشطة. سيعتمد هذا الدرس العملي على رسم مربّعات ملوّنة في الخلايا النشطة وترك الخلايا غير النشطة فارغة.
وهذا يعني أنّه عليك تزويد وحدة معالجة الرسومات بأربع نقاط مختلفة، واحدة لكل ركن من أركان المربع الأربعة. على سبيل المثال، يحتوي المربع الذي تم رسمه في وسط لوحة العرض، والذي تم سحبه من الحواف إلى الداخل، على إحداثيات زوايا على النحو التالي:
ولإدخال هذه الإحداثيات إلى وحدة معالجة الرسومات، عليك وضع القيم في TypedArray. إذا لم تكن على دراية بها من قبل، فإنّ TypedArrays هي مجموعة من عناصر JavaScript التي تتيح لك تخصيص كتل متجاورة من الذاكرة وتفسير كل عنصر في السلسلة كنوع بيانات معيّن. على سبيل المثال، في Uint8Array
، يكون كل عنصر في المصفوفة عبارة عن بايت واحد غير موقّع. تُعدّ TypedArrays رائعة لإرسال البيانات واستلامها من واجهات برمجة التطبيقات التي تتأثر بتنسيق الذاكرة، مثل WebAssembly وWebAudio وWebGPU (بالطبع).
في مثال المربع، بما أنّ القيم كسرية، يكون استخدام Float32Array
مناسبًا.
- أنشئ مصفوفة تحتوي على جميع مواضع الرؤوس في الرسم البياني عن طريق وضع تعريف المصفوفة التالي في الرمز البرمجي. من الأماكن الجيدة لوضعها بالقرب من أعلى الصفحة، أسفل
context.configure()
مباشرةً.
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8,
0.8, -0.8,
0.8, 0.8,
-0.8, 0.8,
]);
يُرجى العِلم أنّ المسافة والتعليق لا يؤثران في القيم، بل هما مخصّصان لتسهيل القراءة. يساعدك ذلك في معرفة أنّ كل زوج من القيم يشكّل إحداثيات المحورين "س" و"ص" لرأس واحد.
ولكن هناك مشكلة! تتذكّر أنّ وحدات معالجة الرسومات تعمل من خلال المثلثات؟ وهذا يعني أنّه عليك تقديم الرؤوس في مجموعات من ثلاثة. لديك مجموعة واحدة من أربعة أشخاص. الحل هو تكرار اثنين من الرؤوس لإنشاء مثلثين يشتركان في ضلع في منتصف المربع.
لتكوين المربع من الرسم البياني، عليك إدراج الرأسين (-0.8, -0.8) و (0.8, 0.8) مرتين، مرة للمثلث الأزرق ومرة للمثلث الأحمر. (يمكنك أيضًا اختيار تقسيم المربع باستخدام الزاويتين الأخريين بدلاً من ذلك، ولن يؤدي ذلك إلى أي اختلاف).
- عدِّل مصفوفة
vertices
السابقة لتصبح على النحو التالي:
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8, // Triangle 1 (Blue)
0.8, -0.8,
0.8, 0.8,
-0.8, -0.8, // Triangle 2 (Red)
0.8, 0.8,
-0.8, 0.8,
]);
على الرغم من أنّ المخطّط البياني يعرض فصلًا بين المثلثين لتوضيح الفكرة، إلا أنّ مواضع الرؤوس هي نفسها تمامًا، وتعرض وحدة معالجة الرسومات المثلثين بدون فجوات. سيتم عرضه كمربّع واحد صلب.
إنشاء مخزن مؤقت للرؤوس
لا يمكن لوحدة معالجة الرسومات رسم رؤوس باستخدام بيانات من مصفوفة JavaScript. تحتوي وحدات معالجة الرسومات في كثير من الأحيان على ذاكرة خاصة بها محسّنة بشكل كبير للعرض، وبالتالي يجب وضع أي بيانات تريد أن تستخدمها وحدة معالجة الرسومات أثناء الرسم في تلك الذاكرة.
بالنسبة إلى العديد من القيم، بما في ذلك بيانات الرؤوس، تتم إدارة الذاكرة من جهة وحدة معالجة الرسومات من خلال كائنات GPUBuffer
. المخزن المؤقت هو جزء من الذاكرة يمكن لوحدة معالجة الرسومات الوصول إليه بسهولة، ويتم وضع علامة عليه لأغراض معيّنة. يمكنك التفكير في الأمر على أنّه يشبه إلى حدّ ما TypedArray مرئيًا لوحدة معالجة الرسومات.
- لإنشاء مخزن مؤقت يحتوي على الرؤوس، أضِف استدعاء الدالة التالي إلى
device.createBuffer()
بعد تعريف مصفوفةvertices
.
index.html
const vertexBuffer = device.createBuffer({
label: "Cell vertices",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
أول ما يجب ملاحظته هو أنّك تمنح المخزن المؤقت تصنيفًا. يمكن منح كل عنصر WebGPU تنشئه تصنيفًا اختياريًا، وننصحك بشدة بإجراء ذلك. التصنيف هو أي سلسلة تريدها، طالما أنّها تساعدك في تحديد ماهية العنصر. وإذا واجهت أي مشاكل، يتم استخدام هذه التصنيفات في رسائل الخطأ التي تنتجها WebGPU لمساعدتك في فهم المشكلة.
بعد ذلك، حدِّد حجمًا لذاكرة التخزين المؤقت بالبايت. أنت بحاجة إلى مخزن مؤقت بسعة 48 بايت، ويمكنك تحديد ذلك من خلال ضرب حجم عدد عشري ذي 32 بت ( 4 بايت) في عدد الأعداد العشرية في مصفوفة vertices
(12). لحسن الحظ، تحسب TypedArrays byteLength تلقائيًا، لذا يمكنك استخدامها عند إنشاء المخزن المؤقت.
أخيرًا، عليك تحديد استخدام المخزن المؤقت. هذا هو أحد العلامات GPUBufferUsage
أو أكثر، ويتم دمج علامات متعددة باستخدام عامل التشغيل |
( bitwise OR). في هذه الحالة، يمكنك تحديد أنّك تريد استخدام المخزن المؤقت لبيانات الرؤوس (GPUBufferUsage.VERTEX
) وأنّك تريد أيضًا أن تتمكّن من نسخ البيانات إليه (GPUBufferUsage.COPY_DST
).
إنّ عنصر المخزن المؤقت الذي يتم إرجاعه إليك غير شفاف، أي لا يمكنك (بسهولة) فحص البيانات التي يتضمّنها. بالإضافة إلى ذلك، معظم سماته غير قابلة للتغيير، فلا يمكنك تغيير حجم GPUBuffer
بعد إنشائه، ولا يمكنك تغيير علامات الاستخدام. ويمكنك تغيير محتوى ذاكرته.
عند إنشاء المخزن المؤقت في البداية، سيتم ضبط الذاكرة التي يحتوي عليها على القيمة صفر. هناك عدة طرق لتغيير محتواه، ولكن الطريقة الأسهل هي استدعاء device.queue.writeBuffer()
باستخدام TypedArray الذي تريد نسخه.
- لنسخ بيانات الرأس إلى ذاكرة المخزن المؤقت، أضِف الرمز التالي:
index.html
device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);
تحديد تنسيق الرأس
أصبح لديك الآن مخزن مؤقت يتضمّن بيانات الرأس، ولكن بالنسبة إلى وحدة معالجة الرسومات، هو مجرد مجموعة من وحدات البايت. يجب تقديم بعض المعلومات الإضافية إذا كنت ستستخدمها في الرسم. يجب أن تتمكّن من إخبار WebGPU بالمزيد عن بنية بيانات الرؤوس.
- حدِّد بنية بيانات الرأس باستخدام قاموس
GPUVertexBufferLayout
:
index.html
const vertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0, // Position, see vertex shader
}],
};
قد يبدو هذا الأمر مربكًا بعض الشيء للوهلة الأولى، ولكن من السهل نسبيًا فهمه.
أول ما تقدّمه هو arrayStride
. هذا هو عدد وحدات البايت التي يجب أن تتخطاها وحدة معالجة الرسومات في المخزن المؤقت عند البحث عن الرأس التالي. يتكوّن كل رأس من رؤوس المربّع من عددَين بنقطة عائمة يبلغ عدد وحداتهما 32 بت. كما ذكرنا سابقًا، يبلغ حجم العدد العشري ذي 32 بت 4 بايت، لذا يبلغ حجم عددين عشريين 8 بايت.
بعد ذلك، تأتي السمة attributes
، وهي عبارة عن مصفوفة. السمات هي أجزاء المعلومات الفردية المرمّزة في كل رأس. لا تحتوي الرؤوس إلا على سمة واحدة (موضع الرأس)، ولكن غالبًا ما تتضمّن حالات الاستخدام الأكثر تقدّمًا رؤوسًا تحتوي على سمات متعدّدة، مثل لون الرأس أو الاتجاه الذي يشير إليه سطح الشكل الهندسي. مع ذلك، لا يتناول هذا الدرس التطبيقي حول الترميز هذه الحالة.
في السمة الفردية، عليك أولاً تحديد format
للبيانات. يأتي ذلك من قائمة بأنواع GPUVertexFormat
التي تصف كل نوع من أنواع بيانات الرؤوس التي يمكن لوحدة معالجة الرسومات فهمها. تحتوي كل زاوية من زواياك على قيمتَين من النوع float بحجم 32 بت، لذا يمكنك استخدام التنسيق float32x2
. إذا كانت بيانات الرأس تتألف بدلاً من ذلك من أربعة أعداد صحيحة غير موقّعة ذات 16 بت لكل منها، على سبيل المثال، يمكنك استخدام uint16x4
بدلاً من ذلك. هل لاحظت النمط؟
بعد ذلك، يوضّح offset
عدد وحدات البايت التي تبدأ بها هذه السمة المحدّدة في الرأس. لا داعي للقلق بشأن ذلك إلا إذا كان المخزن المؤقت يحتوي على أكثر من سمة واحدة، وهو ما لن يحدث خلال هذا الدرس العملي.
أخيرًا، لديك shaderLocation
. هذا رقم عشوائي يتراوح بين 0 و15 ويجب أن يكون فريدًا لكل سمة تحدّدها. وهي تربط هذه السمة بمدخل معيّن في برنامج تظليل الرؤوس، وسنتعرّف على ذلك في القسم التالي.
يُرجى العِلم أنّه على الرغم من تحديد هذه القيم الآن، لن يتم تمريرها فعليًا إلى WebGPU API في أي مكان حتى الآن. سيتم تناول ذلك لاحقًا، ولكن من الأسهل التفكير في هذه القيم عند تحديد رؤوس الأشكال، لذا عليك إعدادها الآن لاستخدامها لاحقًا.
بدء استخدام برامج التظليل
الآن لديك البيانات التي تريد عرضها، ولكن لا يزال عليك إخبار وحدة معالجة الرسومات (GPU) بكيفية معالجتها بالضبط. ويحدث جزء كبير من ذلك باستخدام برامج التظليل.
برامج التظليل هي برامج صغيرة تكتبها وتنفّذها على وحدة معالجة الرسومات. يعمل كل برنامج تظليل على مرحلة مختلفة من البيانات: معالجة الرؤوس أو معالجة التقسيمات أو الحوسبة العامة. وبما أنّها تعمل على وحدة معالجة الرسومات، تكون بنيتها أكثر صرامة من JavaScript العادي. لكنّ هذا الهيكل يسمح لهم بالتنفيذ بسرعة كبيرة، والأهم من ذلك، بالتوازي.
تتم كتابة برامج التظليل في WebGPU بلغة تظليل تُعرف باسم WGSL (لغة التظليل في WebGPU). من الناحية النحوية، تشبه لغة WGSL إلى حدّ ما لغة Rust، وتتضمّن ميزات تهدف إلى تسهيل وتسريع أنواع العمل الشائعة على وحدة معالجة الرسومات (مثل العمليات الحسابية على المتجهات والمصفوفات). إنّ شرح لغة التظليل بالكامل يتجاوز نطاق هذا الدرس البرمجي، ولكن نأمل أن تتعرّف على بعض الأساسيات أثناء الاطّلاع على بعض الأمثلة البسيطة.
يتم تمرير برامج التظليل نفسها إلى WebGPU كسلاسل.
- أنشئ مكانًا لإدخال رمز التظليل عن طريق نسخ ما يلي إلى الرمز أدناه
vertexBufferLayout
:
index.html
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
لإنشاء برامج التظليل التي تستدعيها device.createShaderModule()
، والتي تقدّم لها label
وWGSL code
اختيارية كسلسلة. (يُرجى العِلم أنّه يمكنك استخدام علامات الاقتباس المائلة هنا للسماح بسلاسل متعددة الأسطر). بعد إضافة بعض رموز WGSL الصالحة، تعرض الدالة عنصر GPUShaderModule
يتضمّن النتائج التي تم تجميعها.
تحديد برنامج تظليل الرؤوس
ابدأ ببرنامج تظليل الرؤوس لأنّ وحدة معالجة الرسومات تبدأ به أيضًا.
يتم تعريف برنامج تظليل الرؤوس على أنّه دالة، وتستدعي وحدة معالجة الرسومات هذه الدالة مرة واحدة لكل رأس في vertexBuffer
. بما أنّ vertexBuffer
يحتوي على ستة مواضع (رؤوس)، يتم استدعاء الدالة التي تحدّدها ست مرات. في كل مرة يتم فيها استدعاء الدالة، يتم تمرير موضع مختلف من vertexBuffer
إلى الدالة كوسيطة، وتكون مهمة دالة برنامج تظليل الرؤوس هي عرض موضع مطابق في مساحة القطع.
من المهم أيضًا معرفة أنّه لن يتم بالضرورة الاتصال بهم بترتيب تسلسلي. بدلاً من ذلك، تتفوّق وحدات معالجة الرسومات في تشغيل برامج التظليل هذه بالتوازي، ما يتيح لها معالجة مئات (أو حتى آلاف) الرؤوس في الوقت نفسه. وهذا جزء كبير من سبب السرعة الهائلة لوحدات معالجة الرسومات، ولكن هناك بعض القيود. لضمان التوازي الشديد، لا يمكن أن تتواصل برامج تظليل الرؤوس مع بعضها البعض. يمكن لكل عملية استدعاء shader الاطّلاع على بيانات لرأس واحد فقط في كل مرة، ولا يمكنها إخراج قيم إلا لرأس واحد.
في WGSL، يمكن تسمية دالة تظليل الرأس بأي اسم تريده، ولكن يجب أن تتضمّن @vertex
السمة أمامها للإشارة إلى مرحلة التظليل التي تمثّلها. تحدد WGSL الدوال باستخدام الكلمة الرئيسية fn
، وتستخدم الأقواس لتحديد أي وسيطات، وتستخدم الأقواس المتعرجة لتحديد النطاق.
- أنشئ دالة
@vertex
فارغة، على النحو التالي:
index.html (رمز createShaderModule)
@vertex
fn vertexMain() {
}
مع ذلك، هذا غير صالح، لأنّ برنامج تظليل الرأس يجب أن يعرض على الأقل الموضع النهائي للرأس الذي تتم معالجته في مساحة القطع. يتم تقديم ذلك دائمًا كمتّجه رباعي الأبعاد. تُستخدَم المتجهات بشكل شائع في برامج التظليل، لذا يتم التعامل معها كعناصر أساسية من الدرجة الأولى في اللغة، مع أنواعها الخاصة مثل vec4f
لمتجه رباعي الأبعاد. تتوفّر أنواع مشابهة للمتجهات الثنائية الأبعاد (vec2f
) والمتجهات الثلاثية الأبعاد (vec3f
) أيضًا.
- للإشارة إلى أنّ القيمة التي يتم إرجاعها هي الموضع المطلوب، ضع علامة عليها باستخدام السمة
@builtin(position)
. يُستخدم الرمز->
للإشارة إلى أنّ هذا هو ما تعرضه الدالة.
index.html (createShaderModule code)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
بالطبع، إذا كانت الدالة تتضمّن نوع إرجاع، عليك إرجاع قيمة في نص الدالة. يمكنك إنشاء vec4f
جديد لعرضه باستخدام البنية vec4f(x, y, z, w)
. القيم x
وy
وz
هي جميعًا أرقام فاصلة عائمة تشير في القيمة المعروضة إلى موضع الرأس في مساحة القطع.
- يمكنك عرض قيمة ثابتة
(0, 0, 0, 1)
، وسيكون لديك تقنيًا برنامج تظليل قمة صالح، على الرغم من أنّه لن يعرض أي شيء أبدًا لأنّ وحدة معالجة الرسومات تدرك أنّ المثلثات التي ينتجها هي مجرد نقطة واحدة ثم تتجاهلها.
index.html (createShaderModule code)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}
بدلاً من ذلك، عليك الاستفادة من البيانات من المخزن المؤقت الذي أنشأته، ويمكنك إجراء ذلك من خلال تعريف وسيط للدالة يتضمّن السمة @location()
والنوع اللذين يتطابقان مع ما وصفته في vertexBufferLayout
. لقد حدّدت shaderLocation
بقيمة 0
، لذا في رمز WGSL، ضع علامة @location(0)
على الوسيطة. حدّدت أيضًا التنسيق على أنّه float32x2
، وهو متّجه ثنائي الأبعاد، لذا فإنّ الوسيطة في WGSL هي vec2f
. يمكنك تسميته كما تريد، ولكن بما أنّ هذه القيم تمثّل مواضع الرؤوس، يبدو أنّ اسمًا مثل pos مناسب.
- غيِّر دالة التظليل إلى الرمز التالي:
index.html (createShaderModule code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(0, 0, 0, 1);
}
والآن عليك استعادة هذا المنصب. بما أنّ الموضع هو متّجه ثنائي الأبعاد ونوع الإرجاع هو متّجه رباعي الأبعاد، عليك تعديله قليلاً. ما عليك فعله هو أخذ المكوّنين من وسيطة الموضع ووضعهما في أول مكوّنين من متجه الإرجاع، مع ترك آخر مكوّنين على النحو 0
و1
على التوالي.
- يمكنك عرض الموضع الصحيح من خلال تحديد مكوّنات الموضع التي تريد استخدامها بشكلٍ صريح:
index.html (createShaderModule code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos.x, pos.y, 0, 1);
}
ومع ذلك، بما أنّ هذا النوع من عمليات الربط شائع جدًا في برامج التظليل، يمكنك أيضًا تمرير متّجه الموضع كمعلَمة أولى في صيغة مختصرة ملائمة، وستؤدي الغرض نفسه.
- أعِد كتابة عبارة
return
باستخدام الرمز التالي:
index.html (createShaderModule code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
وهذا هو برنامج تظليل الرؤوس الأولي. هذه الطريقة بسيطة جدًا، إذ يتم فيها نقل الموضع بدون أي تغييرات، ولكنّها مناسبة للبدء.
تحديد برنامج تظليل الأجزاء
التالي هو برنامج تظليل الأجزاء. تعمل برامج تظليل الأجزاء بطريقة مشابهة جدًا لبرامج تظليل الرؤوس، ولكن بدلاً من استدعائها لكل رأس، يتم استدعاؤها لكل بكسل يتم رسمه.
يتم دائمًا طلب برامج تظليل الأجزاء بعد برامج تظليل الرؤوس. تأخذ وحدة معالجة الرسومات ناتج برامج تظليل الرؤوس وتثلّثه، ما يؤدي إلى إنشاء مثلثات من مجموعات من ثلاث نقاط. ثم تحويلها إلى صورة نقطية لكل من هذه المثلثات من خلال تحديد وحدات البكسل في مرفقات الألوان الناتجة التي يتم تضمينها في هذا المثلث، ثم استدعاء برنامج تظليل الأجزاء مرة واحدة لكل من وحدات البكسل هذه. تعرض أداة تظليل الأجزاء لونًا، ويتم احتسابه عادةً من القيم التي يتم إرسالها إليها من أداة تظليل الرؤوس والأصول، مثل مواد العرض، التي تكتبها وحدة معالجة الرسومات في مرفق الألوان.
تمامًا مثل برامج تظليل الرؤوس، يتم تنفيذ برامج تظليل الأجزاء بطريقة متوازية بشكل كبير. تتسم هذه البرامج بمرونة أكبر من برامج تظليل الرؤوس من حيث المدخلات والمخرجات، ولكن يمكنك اعتبارها ببساطة تعرض لونًا واحدًا لكل بكسل من كل مثلث.
يتم الإشارة إلى دالة تظليل الأجزاء في WGSL باستخدام السمة @fragment
، كما أنّها تعرض أيضًا vec4f
. في هذه الحالة، يمثّل المتّجه لونًا وليس موضعًا. يجب منح قيمة الإرجاع السمة @location
للإشارة إلى colorAttachment
من طلب beginRenderPass
الذي يتم كتابة اللون الذي تم إرجاعه إليه. بما أنّ لديك مرفقًا واحدًا فقط، يكون الموقع الجغرافي هو 0.
- أنشئ دالة
@fragment
فارغة، على النحو التالي:
index.html (createShaderModule code)
@fragment
fn fragmentMain() -> @location(0) vec4f {
}
المكوّنات الأربعة للخط المتجه الذي يتم عرضه هي قيم الألوان الأحمر والأخضر والأزرق وقيمة ألفا، ويتم تفسيرها بالطريقة نفسها تمامًا التي تم بها تفسير clearValue
الذي ضبطته في beginRenderPass
سابقًا. إذًا، vec4f(1, 0, 0, 1)
هو أحمر ساطع، ويبدو أنّه لون مناسب للمربّع. يمكنك ضبطه على أي لون تريده.
- اضبط متّجه الألوان الذي تم عرضه على النحو التالي:
index.html (createShaderModule code)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}
وهذا كل ما يتعلق ببرنامج تظليل الأجزاء. إنّها ليست عملية مثيرة للاهتمام، فهي تضبط كل بكسل من كل مثلث على اللون الأحمر، ولكن هذا يكفي في الوقت الحالي.
للتذكير فقط، بعد إضافة رمز Shader الموضّح أعلاه، سيبدو طلب createShaderModule
على النحو التالي:
index.html
const cellShaderModule = device.createShaderModule({
label: 'Cell shader',
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
`
});
إنشاء مسار عرض
لا يمكن استخدام وحدة تظليل للعرض بمفردها. بدلاً من ذلك، عليك استخدامها كجزء من GPURenderPipeline
، يتم إنشاؤها من خلال استدعاء device.createRenderPipeline(). تتحكّم عملية العرض في كيفية رسم الأشكال الهندسية، بما في ذلك العناصر مثل برامج التظليل المستخدَمة، وكيفية تفسير البيانات في مخازن مؤقتة للرؤوس، ونوع الأشكال الهندسية التي يجب عرضها (خطوط ونقاط ومثلثات وما إلى ذلك).
مسار العرض هو العنصر الأكثر تعقيدًا في واجهة برمجة التطبيقات بأكملها، ولكن لا داعي للقلق. معظم القيم التي يمكنك تمريرها إلى هذه الدالة اختيارية، ولا تحتاج إلى تقديم سوى عدد قليل منها للبدء.
- أنشئ مسار عرض على النحو التالي:
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: "auto",
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
يحتاج كل مسار إلى layout
يصف أنواع المدخلات (بخلاف مخازن مؤقتة للرؤوس) التي يحتاج إليها المسار، ولكن ليس لديك أي منها. لحسن الحظ، يمكنك تخطّي "auto"
في الوقت الحالي، وستنشئ عملية العرض تخطيطها الخاص من برامج التظليل.
بعد ذلك، عليك تقديم تفاصيل حول مرحلة vertex
. module
هو GPUShaderModule الذي يحتوي على برنامج تظليل الرأس، بينما يقدّم entryPoint
اسم الدالة في رمز برنامج التظليل الذي يتم استدعاؤه لكل استدعاء رأس. (يمكنك استخدام عدّة دالتين @vertex
و@fragment
في وحدة تظليل واحدة). المخازن المؤقتة هي مصفوفة من عناصر GPUVertexBufferLayout
التي توضّح كيفية حزم بياناتك في مخازن مؤقتة للرؤوس تستخدمها مع خط الأنابيب هذا. لحسن الحظ، سبق أن حدّدت هذا في vertexBufferLayout
! إليك المكان الذي يمكنك فيه إدخالها.
أخيرًا، ستجد تفاصيل حول مرحلة fragment
. يتضمّن ذلك أيضًا وحدة تظليل ونقطة دخول، مثل مرحلة الرأس. آخر خطوة هي تحديد targets
الذي يتم استخدام مسار العرض هذا معه. هذه مصفوفة من القواميس التي تقدّم تفاصيل، مثل الملمس format
، لمرفقات الألوان التي تعرضها سلسلة المعالجة. يجب أن تتطابق هذه التفاصيل مع الأنسجة المقدَّمة في colorAttachments
لأي عمليات عرض يتم استخدام خط الأنابيب هذا معها. يستخدم تمرير العرض نصوصًا من سياق لوحة العرض، ويستخدم القيمة التي حفظتها في canvasFormat
لتنسيقه، لذا عليك تمرير التنسيق نفسه هنا.
هذه ليست كل الخيارات التي يمكنك تحديدها عند إنشاء مسار عرض، ولكنّها كافية لاحتياجات هذا الدرس البرمجي.
رسم المربّع
بهذا نكون قد انتهينا من كل ما تحتاج إليه لرسم المربع.
- لرسم المربّع، ارجع إلى مجموعة استدعاءات
encoder.beginRenderPass()
وpass.end()
، ثم أضِف الأوامر الجديدة التالية بينهما:
index.html
// After encoder.beginRenderPass()
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices
// before pass.end()
يوفّر ذلك لـ WebGPU جميع المعلومات اللازمة لرسم المربّع. أولاً، استخدِم setPipeline()
للإشارة إلى مسار العرض الذي يجب استخدامه للرسم. ويشمل ذلك برامج التظليل المستخدَمة وتنسيق بيانات الرؤوس وبيانات الحالة الأخرى ذات الصلة.
بعد ذلك، يمكنك طلب setVertexBuffer()
باستخدام المخزن المؤقت الذي يحتوي على رؤوس المربّع. يمكنك استدعاء هذا المخزن المؤقت باستخدام 0
لأنّ هذا المخزن المؤقت يتوافق مع العنصر رقم 0 في تعريف vertex.buffers
لخط الأنابيب الحالي.
وأخيرًا، يمكنك إجراء مكالمة draw()
، وهو أمر بسيط بشكل غريب بعد كل عمليات الإعداد السابقة. الشيء الوحيد الذي عليك تمريره هو عدد الرؤوس التي يجب عرضها، والتي يتم استردادها من مخازن بيانات الرؤوس المضبوطة حاليًا وتفسيرها باستخدام مسار العرض المضبوط حاليًا. يمكنك ترميزها بشكل ثابت إلى 6
، ولكن احتسابها من مصفوفة الرؤوس (12 رقمًا عشريًا / إحداثيتان لكل رأس == 6 رؤوس) يعني أنّه إذا قررت استبدال المربع بدائرة مثلاً، سيكون هناك عدد أقل من العناصر التي يجب تعديلها يدويًا.
- أعِد تحميل الشاشة وستظهر (أخيرًا) نتائج كل جهودك: مربّع كبير ملوّن.
5. رسم شبكة
أولاً، توقّف لحظة لتهنئة نفسك. غالبًا ما تكون الخطوة الأولى في عرض أجزاء الهندسة على الشاشة هي الأصعب عند استخدام معظم واجهات برمجة التطبيقات الخاصة بوحدات معالجة الرسومات. يمكن تنفيذ كل ما تفعله من هنا في خطوات أصغر، ما يسهّل عليك التحقّق من تقدّمك أثناء التنفيذ.
في هذا القسم، سنتعرّف على ما يلي:
- كيفية تمرير المتغيرات (المعروفة باسم "المتغيرات الموحّدة") إلى برنامج التظليل من JavaScript
- كيفية استخدام المتغيرات الموحّدة لتغيير سلوك العرض
- كيفية استخدام ميزة "النسخ المتطابق" لرسم العديد من الأشكال الهندسية المختلفة نفسها
تحديد الشبكة
لعرض شبكة، عليك معرفة معلومة أساسية جدًا عنها. كم عدد الخلايا التي يحتوي عليها، سواء من حيث العرض أو الارتفاع؟ يعود إليك تحديد ذلك بصفتك المطوّر، ولكن لتسهيل الأمور قليلاً، تعامَل مع الشبكة على أنّها مربّع (العرض والارتفاع متساويان) واستخدِم حجمًا هو قوة العدد اثنين. (هذا يسهّل بعض العمليات الحسابية لاحقًا). أنت تريد تكبيرها في النهاية، ولكن لبقية هذا القسم، اضبط حجم الشبكة على 4x4 لأنّ ذلك يسهّل توضيح بعض العمليات الحسابية المستخدَمة في هذا القسم. يمكنك توسيع نطاقها لاحقًا.
- حدِّد حجم الشبكة عن طريق إضافة ثابت إلى أعلى رمز JavaScript.
index.html
const GRID_SIZE = 4;
بعد ذلك، عليك تعديل طريقة عرض المربّع لتتمكّن من وضع GRID_SIZE
مربّع في GRID_SIZE
على لوحة العرض. وهذا يعني أنّ المربّع يجب أن يكون أصغر بكثير، ويجب أن يكون هناك الكثير منها.
إحدى الطرق التي يمكنك اتّباعها هي تكبير مخزن بيانات الرؤوس بشكل كبير وتحديد GRID_SIZE
مربّعًا بداخله بالحجم والموضع المناسبَين.GRID_SIZE
في الواقع، لن يكون الرمز البرمجي لذلك سيئًا جدًا. كل ما عليك فعله هو استخدام حلقتَي for وبعض العمليات الحسابية. ولكن هذا لا يحقّق أفضل استخدام لوحدة معالجة الرسومات ويستخدم ذاكرة أكبر من اللازم لتحقيق التأثير. يتناول هذا القسم أسلوبًا أكثر ملاءمة لوحدة معالجة الرسومات.
إنشاء مخزن مؤقت موحّد
أولاً، عليك إبلاغ برنامج التظليل بحجم الشبكة الذي اخترته، لأنّه يستخدمه لتغيير طريقة عرض العناصر. يمكنك ترميز الحجم في برنامج التظليل، ولكن هذا يعني أنّه في كل مرة تريد فيها تغيير حجم الشبكة، عليك إعادة إنشاء برنامج التظليل ومسار العرض، وهو أمر مكلف. والطريقة الأفضل هي توفير حجم الشبكة للتظليل على شكل متغيرات موحّدة.
لقد تعلّمت سابقًا أنّه يتم تمرير قيمة مختلفة من مخزن مؤقت للرؤوس إلى كل استدعاء لبرنامج تظليل الرؤوس. القيمة الموحّدة هي قيمة من المخزن المؤقت تكون هي نفسها في كل عملية استدعاء. وهي مفيدة لنقل القيم الشائعة في جزء من الأشكال الهندسية (مثل موضعها) أو إطار كامل من الرسوم المتحركة (مثل الوقت الحالي) أو حتى العمر الكامل للتطبيق (مثل إعدادات المستخدم المفضّلة).
- أنشئ مخزنًا مؤقتًا موحّدًا عن طريق إضافة الرمز التالي:
index.html
// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
label: "Grid Uniforms",
size: uniformArray.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);
من المفترض أن يبدو هذا الرمز مألوفًا جدًا، لأنّه مطابق تقريبًا للرمز الذي استخدمته لإنشاء مخزن مؤقت للرؤوس في وقت سابق. ويرجع ذلك إلى أنّ القيم الموحّدة يتم إرسالها إلى واجهة برمجة التطبيقات WebGPU من خلال كائنات GPUBuffer نفسها التي يتم إرسال الرؤوس من خلالها، مع الاختلاف الرئيسي في أنّ usage
يتضمّن هذه المرة GPUBufferUsage.UNIFORM
بدلاً من GPUBufferUsage.VERTEX
.
الوصول إلى المتغيرات الموحّدة في برنامج تظليل
- حدِّد قيمة موحّدة من خلال إضافة الرمز التالي:
index.html (استدعاء createShaderModule)
// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos / grid, 0, 1);
}
// ...fragmentMain is unchanged
يحدّد هذا الاسم متغيرًا موحّدًا في برنامج التظليل باسم grid
، وهو عبارة عن متّجه عائم ثنائي الأبعاد يتطابق مع المصفوفة التي نسختها للتو في المخزن المؤقت للمتغيرات الموحّدة. ويحدّد أيضًا أنّ المتغير الموحّد مرتبط عند @group(0)
و@binding(0)
. سنتعرّف على معنى هذه القيم بعد قليل.
بعد ذلك، يمكنك استخدام متّجه الشبكة في أي مكان آخر في رمز التظليل حسب حاجتك. في هذه التعليمات البرمجية، يمكنك تقسيم موضع الرأس على متجه الشبكة. بما أنّ pos
هو متّجه ثنائي الأبعاد وgrid
هو متّجه ثنائي الأبعاد، تنفّذ WGSL عملية قسمة على مستوى كل عنصر. بعبارة أخرى، النتيجة هي نفسها النتيجة التي نحصل عليها عند كتابة vec2f(pos.x / grid.x, pos.y / grid.y)
.
تُعدّ هذه الأنواع من عمليات المتجهات شائعة جدًا في برامج تظليل وحدة معالجة الرسومات لأنّ العديد من تقنيات العرض والحوسبة تعتمد عليها.
ما يعنيه ذلك في حالتك هو أنّه (إذا استخدمت حجم شبكة يبلغ 4) سيكون المربّع الذي تعرضه ربع حجمه الأصلي. هذا مثالي إذا كنت تريد وضع أربعة منها في صف أو عمود.
إنشاء مجموعة ربط
مع ذلك، لا يؤدي تعريف المتغير العام في برنامج التظليل إلى ربطه بالمخزن المؤقت الذي أنشأته. لإجراء ذلك، عليك إنشاء مجموعة ربط وإعدادها.
مجموعة الربط هي مجموعة من الموارد التي تريد إتاحتها لبرنامج التظليل في الوقت نفسه. يمكن أن يتضمّن عدة أنواع من المخازن المؤقتة، مثل المخزن المؤقت الموحّد، وموارد أخرى مثل مواد العرض وأدوات أخذ العينات التي لم يتم تناولها هنا ولكنّها أجزاء شائعة من تقنيات العرض في WebGPU.
- أنشئ مجموعة ربط باستخدام المخزن المؤقت الموحّد عن طريق إضافة الرمز التالي بعد إنشاء المخزن المؤقت الموحّد وخط أنابيب العرض:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}],
});
بالإضافة إلى label
العادي، تحتاج أيضًا إلى layout
يصف أنواع الموارد التي تحتوي عليها مجموعة الربط هذه. هذا ما ستتعمّق فيه أكثر في خطوة مستقبلية، ولكن في الوقت الحالي، يمكنك طلب تخطيط مجموعة الربط من مسار العرض بسعادة لأنّك أنشأت مسار العرض باستخدام layout: "auto"
. يؤدي ذلك إلى إنشاء تخطيطات مجموعات الربط تلقائيًا من عمليات الربط التي حدّدتها في رمز التظليل نفسه. في هذه الحالة، تطلب من getBindGroupLayout(0)
، حيث يتوافق 0
مع @group(0)
الذي كتبته في برنامج التظليل.
بعد تحديد التنسيق، عليك تقديم مصفوفة من entries
. كل إدخال هو قاموس يتضمّن القيم التالية على الأقل:
binding
، الذي يتطابق مع القيمة@binding()
التي أدخلتها في برنامج التظليل في هذه الحالة،0
.resource
: هي المورد الفعلي الذي تريد عرضه للمتغير في فهرس الربط المحدّد. في هذه الحالة، يكون المخزن المؤقت الموحّد.
تعرض الدالة GPUBindGroup
، وهو معرّف مبهم وغير قابل للتغيير. لا يمكنك تغيير الموارد التي تشير إليها مجموعة الربط بعد إنشائها، ولكن يمكنك تغيير محتوى هذه الموارد. على سبيل المثال، إذا غيّرت المخزن المؤقت الموحّد ليحتوي على حجم شبكة جديد، سينعكس ذلك في طلبات الرسم المستقبلية التي تستخدم مجموعة الربط هذه.
ربط مجموعة الربط
بعد إنشاء مجموعة الربط، عليك إخبار WebGPU باستخدامها عند الرسم. لحسن الحظ، هذا الأمر بسيط جدًا.
- ارجع إلى تمريرة العرض وأضِف السطر الجديد قبل طريقة
draw()
:
index.html
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup); // New line!
pass.draw(vertices.length / 2);
يتوافق 0
الذي تم تمريره كمعلَمة أولى مع @group(0)
في رمز Shader. أنت تقول إنّ كل @binding
يشكّل جزءًا من @group(0)
ويستخدم الموارد في مجموعة الربط هذه.
والآن، أصبح المخزن المؤقت الموحّد متاحًا لبرنامج التظليل.
- أعِد تحميل الصفحة، وسيظهر لك ما يلي:
رائع! أصبح المربّع الآن ربع حجمه السابق. هذا ليس كثيرًا، لكنّه يوضّح أنّه تم تطبيق المتغيّر الموحّد وأنّ برنامج التظليل يمكنه الآن الوصول إلى حجم الشبكة.
التلاعب بالأشكال الهندسية في برنامج التظليل
بعد أن أصبح بإمكانك الرجوع إلى حجم الشبكة في برنامج التظليل، يمكنك البدء في إجراء بعض العمليات لتعديل الأشكال الهندسية التي تعرضها لتناسب نمط الشبكة المطلوب. للقيام بذلك، فكِّر جيدًا في ما تريد تحقيقه.
عليك تقسيم لوحة العرض بشكلٍ نظري إلى خلايا فردية. للحفاظ على الاصطلاح الذي ينص على أنّ المحور X يزداد كلما تحرّكت إلى اليمين والمحور Y يزداد كلما تحرّكت إلى الأعلى، افترض أنّ الخلية الأولى تقع في الزاوية السفلية اليسرى من لوحة العرض. يمنحك ذلك تصميمًا يبدو على النحو التالي، مع الشكل المربّع الحالي في المنتصف:
التحدي الذي يواجهك هو العثور على طريقة في برنامج التظليل تتيح لك تحديد موضع شكل المربّع في أي من هذه الخلايا باستخدام إحداثيات الخلية.
أولاً، يمكنك ملاحظة أنّ المربّع غير محاذٍ بشكل جيد لأي من الخلايا لأنّه تم تحديده ليحيط بمركز لوحة العرض. عليك نقل المربّع بمقدار نصف خلية لكي يتماشى بشكل جيد مع الخلايا.
يمكنك حلّ هذه المشكلة من خلال تعديل مخزن بيانات الرؤوس للمربّع. من خلال تحريك الرؤوس بحيث تكون الزاوية السفلية اليسرى عند (0.1، 0.1) مثلاً بدلاً من (-0.8، -0.8)، يمكنك تحريك هذا المربع ليصطف مع حدود الخلية بشكل أفضل. ولكن بما أنّ لديك تحكّمًا كاملاً في كيفية معالجة الرؤوس في برنامج التظليل، من السهل جدًا دفعها إلى مكانها باستخدام رمز برنامج التظليل.
- عدِّل وحدة تظليل الرأس باستخدام الرمز التالي:
index.html (استدعاء createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
// Add 1 to the position before dividing by the grid size.
let gridPos = (pos + 1) / grid;
return vec4f(gridPos, 0, 1);
}
يؤدي ذلك إلى نقل كل رأس للأعلى ولليسار بمقدار واحد (وهو، كما تتذكر، نصف مساحة القطع) قبل قسمته على حجم الشبكة. والنتيجة هي مربّع محاذى بشكل جيد للشبكة يقع خارج نقطة الأصل مباشرةً.
بعد ذلك، بما أنّ نظام الإحداثيات في لوحة العرض يضع (0, 0) في المنتصف و(1-, 1-) في أسفل اليمين، وأنت تريد أن يكون (0, 0) في أسفل اليمين، عليك نقل موضع الشكل الهندسي بمقدار (1-, 1-) بعد القسمة على حجم الشبكة لنقله إلى تلك الزاوية.
- ترجمة موضع الشكل الهندسي، على النحو التالي:
index.html (استدعاء createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
// Subtract 1 after dividing by the grid size.
let gridPos = (pos + 1) / grid - 1;
return vec4f(gridPos, 0, 1);
}
والآن، أصبح المربع في الموضع الصحيح في الخلية (0, 0).
ماذا لو أردت وضعه في خلية مختلفة؟ يمكنك معرفة ذلك من خلال تعريف متّجه cell
في برنامج التظليل وتعبئته بقيمة ثابتة مثل let cell = vec2f(1, 1)
.
إذا أضفت ذلك إلى gridPos
، سيؤدي ذلك إلى إلغاء - 1
في الخوارزمية، وهذا ليس ما تريده. بدلاً من ذلك، عليك نقل المربع بمقدار وحدة شبكة واحدة فقط (ربع لوحة العرض) لكل خلية. يبدو أنّ عليك إجراء عملية قسمة أخرى على grid
.
- غيِّر موضع الشبكة، على النحو التالي:
index.html (استدعاء createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1); // Cell(1,1) in the image above
let cellOffset = cell / grid; // Compute the offset to cell
let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!
return vec4f(gridPos, 0, 1);
}
إذا أعدت تحميل الصفحة الآن، سيظهر لك ما يلي:
همم. هذا ليس ما كنت تبحث عنه.
والسبب في ذلك هو أنّ إحداثيات لوحة الرسم تتراوح بين -1 و+1، لذا فإنّ عرضها يبلغ وحدتَين. هذا يعني أنّه إذا أردت نقل نقطة بمقدار ربع مساحة لوحة العرض، عليك نقلها بمقدار 0.5 وحدة. هذا خطأ يسهل الوقوع فيه عند التعامل مع إحداثيات وحدة معالجة الرسومات. لحسن الحظ، يمكن حلّ هذه المشكلة بسهولة.
- اضرب قيمة الإزاحة في 2، كما يلي:
index.html (استدعاء createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1);
let cellOffset = cell / grid * 2; // Updated
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
وهذا يمنحك ما تريده بالضبط.
تبدو لقطة الشاشة على النحو التالي:
بالإضافة إلى ذلك، يمكنك الآن ضبط cell
على أي قيمة ضمن حدود الشبكة، ثم إعادة التحميل لرؤية المربّع معروضًا في الموقع الجغرافي المطلوب.
رسم مثيلات
بعد أن أصبح بإمكانك وضع المربع في المكان الذي تريده باستخدام بعض العمليات الحسابية، تتمثل الخطوة التالية في عرض مربع واحد في كل خلية من خلايا الشبكة.
إحدى الطرق التي يمكنك اتّباعها هي كتابة إحداثيات الخلايا في مخزن مؤقت موحّد، ثم استدعاء draw مرة واحدة لكل مربع في الشبكة، وتعديل المخزن المؤقت الموحّد في كل مرة. ومع ذلك، سيكون ذلك بطيئًا جدًا، لأنّ وحدة معالجة الرسومات يجب أن تنتظر أن يكتب JavaScript الإحداثيات الجديدة في كل مرة. من أهم العوامل التي تساهم في تحسين أداء وحدة معالجة الرسومات هو تقليل الوقت الذي تستغرقه في انتظار أجزاء أخرى من النظام.
بدلاً من ذلك، يمكنك استخدام تقنية تُعرف باسم "النسخ". النسخ المتطابق هو طريقة لإخبار وحدة معالجة الرسومات برسم نُسخ متعدّدة من الشكل الهندسي نفسه باستخدام طلب واحد إلى draw
، وهو أسرع بكثير من استدعاء draw
مرة واحدة لكل نسخة. يُشار إلى كل نسخة من الشكل الهندسي باسم مثيل.
- لإخبار وحدة معالجة الرسومات (GPU) بأنّك تريد عددًا كافيًا من مربّعك لملء الشبكة، أضِف وسيطًا واحدًا إلى طلب الرسم الحالي:
index.html
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
يُعلم هذا النظام بأنّك تريد رسم رؤوس المربّع الستة (vertices.length / 2
) 16 مرة (GRID_SIZE * GRID_SIZE
). ولكن إذا أعدت تحميل الصفحة، سيظلّ بإمكانك رؤية ما يلي:
لماذا؟ حسنًا، السبب هو أنّك ترسم كل هذه المربعات الـ 16 في الموضع نفسه. يجب أن يتضمّن برنامج التظليل بعض المنطق الإضافي الذي يعيد تحديد موضع الأشكال الهندسية على أساس كل مثيل.
في برنامج التظليل، بالإضافة إلى سمات الرأس مثل pos
التي تأتي من مخزن الرأس المؤقت، يمكنك أيضًا الوصول إلى ما يُعرف باسم القيم المضمّنة في WGSL. هذه هي القيم التي يتم احتسابها بواسطة WebGPU، وإحدى هذه القيم هي instance_index
. instance_index
هو عدد صحيح غير موقّع من 32 بت يتراوح بين 0
وnumber of instances - 1
يمكنك استخدامه كجزء من منطق برنامج التظليل. وتكون قيمته هي نفسها لكل رأس تمت معالجته وهو جزء من المثيل نفسه. وهذا يعني أنّه سيتم استدعاء برنامج تظليل الرؤوس ست مرات مع قيمة instance_index
تساوي 0
، وذلك مرة واحدة لكل موضع في مخزن بيانات الرؤوس. ثم ست مرات أخرى مع instance_index
بقيمة 1
، ثم ست مرات أخرى مع instance_index
بقيمة 2
، وهكذا.
لرؤية هذا الإجراء، عليك إضافة instance_index
المضمّن إلى مدخلات التظليل. يمكنك إجراء ذلك بالطريقة نفسها التي تحدّد بها موضع الإعلان، ولكن بدلاً من وضع علامة باستخدام السمة @location
، استخدِم @builtin(instance_index)
، ثمّ سمِّ الوسيط بالاسم الذي تريده. (يمكنك تسميته instance
ليتطابق مع نموذج الرمز البرمجي). بعد ذلك، استخدِمها كجزء من منطق التظليل.
- استخدِم
instance
بدلاً من إحداثيات الخلية:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance); // Save the instance_index as a float
let cell = vec2f(i, i); // Updated
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
إذا أعدت تحميل الصفحة الآن، ستلاحظ أنّ لديك أكثر من مربّع واحد. ولكن لا يمكنك رؤية جميع الألوان الـ 16.
يرجع ذلك إلى أنّ إحداثيات الخلايا التي تنشئها هي (0, 0)، و(1, 1)، و(2, 2)... وصولاً إلى (15, 15)، ولكن أول أربعة منها فقط تتناسب مع لوحة العرض. لإنشاء الشبكة التي تريدها، عليك تحويل instance_index
بحيث يرتبط كل فهرس بخلية فريدة ضمن شبكتك، كما يلي:
العملية الحسابية لذلك بسيطة إلى حدّ ما. بالنسبة إلى قيمة X لكل خلية، يجب الحصول على باقي القسمة بين instance_index
وعرض الشبكة، ويمكن إجراء ذلك في WGSL باستخدام عامل التشغيل %
. وبالنسبة إلى قيمة Y لكل خلية، يجب قسمة instance_index
على عرض الشبكة، مع تجاهل أيّ كسور متبقية. يمكنك إجراء ذلك باستخدام الدالة floor()
في WGSL.
- غيِّر العمليات الحسابية على النحو التالي:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
// Compute the cell coordinate from the instance_index
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
بعد إجراء هذا التعديل على الرمز، ستحصل أخيرًا على شبكة المربّعات التي طال انتظارها.
- بعد أن أصبح يعمل، ارجع وارفع حجم الشبكة.
index.html
const GRID_SIZE = 32;
بهذه السهولة! يمكنك الآن تكبير هذه الشبكة بشكل كبير جدًا، وسيتمكّن متوسط وحدة معالجة الرسومات من التعامل معها بشكل جيد. لن ترى المربّعات الفردية قبل فترة طويلة من مواجهة أي مشاكل في أداء وحدة معالجة الرسومات.
6. مكافأة إضافية: اجعلها أكثر ألوانًا
في هذه المرحلة، يمكنك الانتقال بسهولة إلى القسم التالي بعد أن وضعت الأساس لبقية الدرس العملي. مع أنّ شبكة المربّعات التي تشترك في اللون نفسه تؤدي الغرض، إلا أنّها ليست جذابة تمامًا، أليس كذلك؟ لحسن الحظ، يمكنك جعل الأمور أكثر إشراقًا باستخدام بعض الرياضيات ورمز التظليل الإضافي.
استخدام البُنى في برامج التظليل
حتى الآن، كنت تمرّر جزءًا واحدًا من البيانات خارج برنامج تظليل الرؤوس: وهو الموضع المحوَّل. ولكن يمكنك في الواقع عرض المزيد من البيانات من برنامج تظليل الرؤوس واستخدامها في برنامج تظليل الأجزاء.
الطريقة الوحيدة لنقل البيانات خارج برنامج تظليل الرؤوس هي عرضها. يجب دائمًا أن تعرض أداة تظليل الرؤوس موضعًا، لذا إذا أردت عرض أي بيانات أخرى معها، عليك وضعها في بنية. في WGSL، تكون البُنى عبارة عن أنواع كائنات مسماة تحتوي على سمة واحدة أو أكثر مسماة. يمكن أيضًا ترميز المواقع باستخدام سمات مثل @builtin
و@location
. يتم تعريفها خارج أي دوال، ويمكنك بعد ذلك تمرير مثيلات منها إلى الدوال وخارجها حسب الحاجة. على سبيل المثال، ضع في اعتبارك برنامج تظليل الرؤوس الحالي:
index.html (استدعاء createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
- عبِّر عن الشيء نفسه باستخدام البُنى لإدخال الدالة وإخراجها:
index.html (استدعاء createShaderModule)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
return output;
}
يُرجى العِلم أنّ هذا يتطلّب منك الإشارة إلى موضع الإدخال وفهرس المثيل باستخدام input
، ويجب تعريف البنية التي تعرضها أولاً كمتغيّر وتحديد خصائصها الفردية. في هذه الحالة، لا يحدث فرق كبير، بل إنّ استخدام البُنى يجعل دالة التظليل أطول قليلاً، ولكن كلما أصبحت برامج التظليل أكثر تعقيدًا، يمكن أن يكون استخدام البُنى طريقة رائعة للمساعدة في تنظيم البيانات.
تمرير البيانات بين دالتَي الرأس والجزئيات
للتذكير، وظيفة @fragment
بسيطة قدر الإمكان:
index.html (استدعاء createShaderModule)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
أنت لا تتلقّى أي مدخلات، وتمرّر لونًا ثابتًا (أحمر) كمخرجات. إذا كان برنامج التظليل يعرف المزيد عن الأشكال الهندسية التي يلوّنها، يمكنك استخدام هذه البيانات الإضافية لجعل الأشياء أكثر إثارة للاهتمام. على سبيل المثال، ماذا لو أردت تغيير لون كل مربع استنادًا إلى إحداثيات الخلية؟ تعرف المرحلة @vertex
الخلية التي يتم عرضها، ما عليك سوى تمريرها إلى المرحلة @fragment
.
لتمرير أي بيانات بين مرحلتَي الرأس والجزئيات، عليك تضمينها في بنية إخراج مع @location
من اختيارنا. بما أنّك تريد تمرير إحداثيات الخلية، أضِفها إلى بنية VertexOutput
من الخطوة السابقة، ثم اضبطها في الدالة @vertex
قبل إرجاعها.
- غيِّر قيمة الإرجاع لبرنامج تظليل الرؤوس، كما يلي:
index.html (استدعاء createShaderModule)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) cell: vec2f, // New line!
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell; // New line!
return output;
}
- في الدالة
@fragment
، استلم القيمة عن طريق إضافة وسيطة تحمل@location
نفسه. (ليس من الضروري أن تتطابق الأسماء، ولكن من الأسهل تتبُّع الأمور إذا تطابقت).
index.html (استدعاء createShaderModule)
@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
// Remember, fragment return values are (Red, Green, Blue, Alpha)
// and since cell is a 2D vector, this is equivalent to:
// (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
return vec4f(cell, 0, 1);
}
- بدلاً من ذلك، يمكنك استخدام بنية بدلاً من ذلك:
index.html (استدعاء createShaderModule)
struct FragInput {
@location(0) cell: vec2f,
};
@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
- هناك بديل آخر، بما أنّ هاتين الدالتين معرّفتان في وحدة تظليل واحدة، وهو إعادة استخدام بنية الإخراج في مرحلة
@vertex
. ويسهّل ذلك تمرير القيم لأنّ الأسماء والمواقع الجغرافية تكون متسقة بشكل طبيعي.
index.html (استدعاء createShaderModule)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
بغض النظر عن النمط الذي اخترته، النتيجة هي أنّه يمكنك الوصول إلى رقم الخلية في الدالة @fragment
واستخدامه للتأثير في اللون. مع أيّ من الرموز البرمجية أعلاه، تبدو النتيجة على النحو التالي:
أصبحت الألوان أكثر تنوّعًا الآن، ولكنّها ليست جذابة تمامًا. قد تتساءل عن سبب اختلاف الصفين الأيمن والسفلي فقط. يرجع ذلك إلى أنّ قيم الألوان التي تعرضها الدالة @fragment
تتوقّع أن تكون كل قناة في النطاق من 0 إلى 1، ويتم حصر أي قيم خارج هذا النطاق فيه. من ناحية أخرى، تتراوح قيم الخلايا بين 0 و32 على طول كل محور. لذا، نرى هنا أنّ الصف الأول والعمود الأول قد بلغا القيمة الكاملة 1 على الفور في قناة الألوان الحمراء أو الخضراء، ويتم تثبيت كل خلية بعد ذلك على القيمة نفسها.
إذا أردت انتقالًا أكثر سلاسة بين الألوان، عليك عرض قيمة كسرية لكل قناة لون، ويفضّل أن تبدأ من صفر وتنتهي عند واحد على طول كل محور، ما يعني قسمة أخرى على grid
.
- غيِّر برنامج تظليل الأجزاء على النحو التالي:
index.html (استدعاء createShaderModule)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell/grid, 0, 1);
}
أعِد تحميل الصفحة، وستلاحظ أنّ الرمز الجديد يوفّر تدرّجًا أجمل بكثير للألوان على مستوى الشبكة بأكملها.
وعلى الرغم من أنّ هذا التحسين مفيد، إلا أنّه يظهر الآن جزء مظلم في أسفل يسار الشاشة، حيث تصبح الشبكة سوداء. عند بدء محاكاة "لعبة الحياة"، سيحجب جزء من الشبكة يصعب رؤيته ما يحدث. سيكون من الجيد تحسينها.
لحسن الحظ، لديك قناة ألوان كاملة غير مستخدَمة، وهي اللون الأزرق، يمكنك استخدامها. التأثير الذي تريده بشكل مثالي هو أن يكون اللون الأزرق أكثر سطوعًا في الأماكن التي تكون فيها الألوان الأخرى أغمق، ثم يتلاشى كلما زادت كثافة الألوان الأخرى. أسهل طريقة لإجراء ذلك هي البدء بالقناة 1 وطرح واحد من قيم الخلايا. يمكن أن تكون القيمة c.x
أو c.y
. جرِّب كليهما، ثم اختَر ما تفضّله.
- أضِف ألوانًا أكثر سطوعًا إلى برنامج تظليل الأجزاء، مثل ما يلي:
createShaderModule call
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let c = input.cell / grid;
return vec4f(c, 1-c.x, 1);
}
تبدو النتيجة رائعة.
هذه ليست خطوة مهمة. ولكن بما أنّها تبدو أفضل، تم تضمينها في ملف مصدر نقطة التفتيش المقابلة، وتعكس بقية لقطات الشاشة في هذا الدرس البرمجي الشبكة الأكثر ألوانًا.
7. إدارة حالة الخلية
بعد ذلك، عليك التحكّم في الخلايا التي يتم عرضها على الشبكة، استنادًا إلى بعض الحالات المخزَّنة على وحدة معالجة الرسومات. هذا مهم للمحاكاة النهائية.
كل ما تحتاج إليه هو إشارة تشغيل/إيقاف لكل خلية، لذا فإنّ أي خيارات تتيح لك تخزين مجموعة كبيرة من أي نوع من القيم ستكون مناسبة. قد تعتقد أنّ هذه حالة استخدام أخرى للمخازن المؤقتة الموحّدة. على الرغم من أنّه يمكنك إنجاز ذلك، إلا أنّ الأمر أكثر صعوبة لأنّ مخازن البيانات الموحّدة محدودة الحجم، ولا يمكنها التعامل مع المصفوفات ذات الحجم الديناميكي (عليك تحديد حجم المصفوفة في برنامج التظليل)، ولا يمكن لبرامج التظليل الحسابية الكتابة إليها. العنصر الأخير هو الأكثر إشكالية، لأنّك تريد إجراء محاكاة "لعبة الحياة" على وحدة معالجة الرسومات في برنامج تظليل حسابي.
لحسن الحظ، يتوفّر خيار آخر للمخزن المؤقت يتجنّب كل هذه القيود.
إنشاء مخزن مؤقت
مخازن التخزين المؤقت هي مخازن مؤقتة للاستخدام العام يمكن قراءتها وكتابتها في برامج التظليل الحسابية، وقراءتها في برامج تظليل الرؤوس. يمكن أن تكون كبيرة جدًا، ولا تحتاج إلى حجم محدّد في برنامج تظليل، ما يجعلها أشبه بالذاكرة العامة. وهذا ما تستخدمه لتخزين حالة الخلية.
- لإنشاء مخزن مؤقت لحالة الخلية، استخدِم مقتطفًا من رمز إنشاء المخزن المؤقت الذي من المحتمل أن يكون مألوفًا لك الآن:
index.html
// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);
// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
label: "Cell State",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
تمامًا كما هو الحال مع مخازن البيانات الموحّدة ومخازن بيانات الرؤوس، استخدِم الدالة device.createBuffer()
مع الحجم المناسب، ثم احرص على تحديد استخدام GPUBufferUsage.STORAGE
هذه المرة.
يمكنك ملء المخزن المؤقت بالطريقة نفسها كما كان من قبل عن طريق ملء TypedArray بالحجم نفسه بالقيم ثم استدعاء device.queue.writeBuffer()
. بما أنّك تريد معرفة تأثير المخزن المؤقت على الشبكة، ابدأ بملئه بشيء يمكن توقّعه.
- فعِّل كل خلية ثالثة باستخدام الرمز التالي:
index.html
// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);
قراءة المخزن المؤقت للتخزين في برنامج التظليل
بعد ذلك، عدِّل برنامج التظليل لكي يطّلع على محتوى مخزن البيانات المؤقتة قبل عرض الشبكة. يبدو أنّ هذه الطريقة مشابهة جدًا لطريقة إضافة الزي الرسمي سابقًا.
- عدِّل برنامج تظليل الأشكال باستخدام الرمز التالي:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!
أولاً، عليك إضافة نقطة الربط التي يتم إدخالها أسفل شبكة المربّعات مباشرةً. تريد الاحتفاظ بقيمة @group
نفسها التي تتضمّنها قيمة grid
الموحّدة، ولكن يجب أن يختلف رقم @binding
. نوع var
هو storage
، وذلك لعرض نوع المخزن المؤقت المختلف، وبدلاً من متجه واحد، يكون النوع الذي تحدّده لـ cellState
عبارة عن مصفوفة من قيم u32
، وذلك لمطابقة Uint32Array
في JavaScript.
بعد ذلك، في نص الدالة @vertex
، استعلم عن حالة الخلية. بما أنّ الحالة مخزّنة في مصفوفة مسطّحة في مخزن البيانات المؤقت، يمكنك استخدام instance_index
للبحث عن قيمة الخلية الحالية.
كيف يمكن إيقاف خلية إذا كانت الحالة تشير إلى أنّها غير نشطة؟ حسنًا، بما أنّ حالتي التفعيل وعدم التفعيل اللتين تحصل عليهما من المصفوفة هما 1 أو 0، يمكنك تغيير حجم الشكل الهندسي حسب حالة التفعيل. يؤدي تغيير حجمها بمقدار 1 إلى ترك الشكل الهندسي كما هو، ويؤدي تغيير حجمها بمقدار 0 إلى انهيار الشكل الهندسي في نقطة واحدة، ثم تتجاهله وحدة معالجة الرسومات.
- عدِّل رمز التظليل لتغيير حجم الموضع حسب حالة الخلية النشطة. يجب تحويل قيمة الحالة إلى
f32
من أجل استيفاء متطلبات أمان النوع في WGSL:
index.html
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) -> VertexOutput {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let state = f32(cellState[instance]); // New line!
let cellOffset = cell / grid * 2;
// New: Scale the position by the cell's active state.
let gridPos = (pos*state+1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell;
return output;
}
إضافة مخزن مؤقت للتخزين إلى مجموعة الربط
قبل أن تتمكّن من رؤية تأثير حالة الخلية، أضِف مخزن البيانات المؤقت إلى مجموعة ربط. بما أنّها جزء من @group
نفسه مثل المخزن المؤقت الموحّد، أضِفها إلى مجموعة الربط نفسها في رمز JavaScript أيضًا.
- أضِف المورد الاحتياطي لمساحة التخزين على النحو التالي:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
},
// New entry!
{
binding: 1,
resource: { buffer: cellStateStorage }
}],
});
تأكَّد من أنّ binding
للإدخال الجديد يتطابق مع @binding()
للقيمة المقابلة في برنامج التظليل.
بعد إعداد ذلك، يجب أن تتمكّن من إعادة تحميل الصفحة ورؤية النمط يظهر في الشبكة.
استخدام نمط المخزن المؤقت للتبديل
تستخدم معظم عمليات المحاكاة، مثل تلك التي تنشئها، نسختَين على الأقل من حالتها. في كل خطوة من خطوات المحاكاة، تتم القراءة من نسخة واحدة من الحالة والكتابة إلى النسخة الأخرى. بعد ذلك، في الخطوة التالية، اقلب البطاقة واقرأ من الحالة التي كتبوا فيها سابقًا. يُشار إلى ذلك عادةً باسم نمط الذهاب والإياب لأنّ أحدث إصدار من الحالة ينتقل ذهابًا وإيابًا بين نُسخ الحالة في كل خطوة.
لماذا هذا الإجراء ضروري؟ لنلقِ نظرة على مثال بسيط: تخيَّل أنّك تكتب محاكاة بسيطة جدًا تنقل فيها أي مربّعات نشطة إلى اليمين بمقدار خلية واحدة في كل خطوة. للحفاظ على سهولة الفهم، يمكنك تحديد البيانات والمحاكاة في JavaScript:
// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];
function simulate() {
for (let i = 0; i < state.length-1; ++i) {
if (state[i] == 1) {
state[i] = 0;
state[i+1] = 1;
}
}
}
simulate(); // Run the simulation for one step.
ولكن إذا شغّلت هذا الرمز، ستنتقل الخلية النشطة إلى نهاية الصفيف في خطوة واحدة. لماذا؟ لأنّك تواصل تعديل الحالة في مكانها، لذا تنتقل إلى الخلية النشطة على اليسار، ثم تنظر إلى الخلية التالية و... ها هي! تم تفعيلها. يُرجى نقله إلى اليسار مرة أخرى. إنّ تغيير البيانات في الوقت نفسه الذي تراقبها فيه يؤدي إلى إتلاف النتائج.
باستخدام نمط "بينغ بونغ"، يمكنك التأكّد من أنّك تنفّذ دائمًا الخطوة التالية من المحاكاة باستخدام نتائج الخطوة الأخيرة فقط.
// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];
function simulate(inArray, outArray) {
outArray[0] = 0;
for (let i = 1; i < inArray.length; ++i) {
outArray[i] = inArray[i-1];
}
}
// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA);
- استخدِم هذا النمط في الرمز الخاص بك من خلال تعديل تخصيص مخزن البيانات المؤقتة لإنشاء مخزنَين متطابقَين:
index.html
// Create two storage buffers to hold the cell state.
const cellStateStorage = [
device.createBuffer({
label: "Cell State A",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
}),
device.createBuffer({
label: "Cell State B",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
})
];
- للمساعدة في توضيح الفرق بين المخزنَين المؤقتَين، املأهما ببيانات مختلفة:
index.html
// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
- لعرض مخازن التخزين المؤقت المختلفة في عملية العرض، عدِّل مجموعات الربط لتضمين نوعَين مختلفَين أيضًا:
index.html
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}],
})
];
إعداد حلقة عرض
حتى الآن، لم يتم إجراء سوى عملية رسم واحدة لكل عملية إعادة تحميل للصفحة، ولكنك تريد الآن عرض البيانات التي يتم تعديلها بمرور الوقت. لإجراء ذلك، تحتاج إلى حلقة عرض بسيطة.
حلقة العرض هي حلقة تتكرر بلا نهاية وترسم المحتوى على لوحة العرض بمعدل تكرار معيّن. تستخدم العديد من الألعاب والمحتوى الآخر الذي يريد عرض رسوم متحركة سلسة الدالة requestAnimationFrame()
لجدولة عمليات معاودة الاتصال بالمعدل نفسه الذي يتم به إعادة تحميل الشاشة (60 مرة كل ثانية).
يمكن لهذا التطبيق استخدام ذلك أيضًا، ولكن في هذه الحالة، من المحتمل أنّك تريد أن تحدث التحديثات على فترات أطول حتى تتمكّن من متابعة ما تقوم به المحاكاة بسهولة أكبر. يمكنك إدارة التكرار بنفسك بدلاً من ذلك حتى تتمكّن من التحكّم في معدّل تعديل المحاكاة.
- أولاً، اختَر معدّلًا لتعديل المحاكاة (200 ملي ثانية هو معدّل جيد، ولكن يمكنك اختيار معدّل أبطأ أو أسرع إذا أردت)، ثم تتبَّع عدد خطوات المحاكاة التي تم إكمالها.
index.html
const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
- بعد ذلك، انقل كل الرمز الذي تستخدمه حاليًا لعرض الإعلانات إلى دالة جديدة. جدوِل هذه الدالة لتتكرّر بالفاصل الزمني الذي تريده باستخدام
setInterval()
. تأكَّد من أنّ الدالة تعدِّل أيضًا عدد الخطوات، واستخدِمها لاختيار إحدى مجموعتَي الربط.
index.html
// Move all of our rendering code into a function
function updateGrid() {
step++; // Increment the step count
// Start a render pass
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
storeOp: "store",
}]
});
// Draw the grid.
pass.setPipeline(cellPipeline);
pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
// End the render pass and submit the command buffer
pass.end();
device.queue.submit([encoder.finish()]);
}
// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);
والآن، عند تشغيل التطبيق، سترى أنّ لوحة العرض تتنقل ذهابًا وإيابًا بين مخزنَي الحالة اللذين أنشأتهما.
بهذا تكون قد انتهيت تقريبًا من عملية العرض. أنت الآن على استعداد لعرض نتائج محاكاة "لعبة الحياة" التي ستنشئها في الخطوة التالية، حيث ستبدأ أخيرًا في استخدام برامج التظليل الحسابية.
من الواضح أنّ إمكانات العرض في WebGPU تتضمّن الكثير من الميزات التي لم يتم تناولها هنا، ولكنّها خارج نطاق هذا الدرس العملي. نأمل أن يقدّم لك هذا الشرح فكرة كافية عن طريقة عمل العرض في WebGPU، وأن يساعدك في فهم التقنيات الأكثر تقدّمًا، مثل العرض الثلاثي الأبعاد.
8. تشغيل المحاكاة
الآن، ننتقل إلى الجزء الأخير من اللغز: تنفيذ محاكاة "لعبة الحياة" في برنامج تظليل حاسوبي.
استخدام برامج تظليل الحوسبة أخيرًا
لقد تعرّفت على برامج التظليل الحسابية بشكل مجرّد خلال هذا الدرس العملي، ولكن ما هي بالضبط؟
تتشابه برامج التظليل الحسابية مع برامج تظليل الرؤوس وبرامج تظليل الأجزاء في أنّها مصمَّمة للتشغيل بتوازٍ شديد على وحدة معالجة الرسومات، ولكن على عكس مرحلتَي تظليل الرؤوس والأجزاء، ليس لديها مجموعة محدَّدة من المدخلات والمخرجات. يمكنك قراءة البيانات وكتابتها حصريًا من المصادر التي تختارها، مثل مخازن البيانات المؤقتة. وهذا يعني أنّه بدلاً من تنفيذ الدالة مرة واحدة لكل رأس أو مثيل أو بكسل، عليك تحديد عدد مرات استدعاء دالة Shader التي تريدها. بعد ذلك، عند تشغيل برنامج التظليل، يتم إخبارك بالاستدعاء الذي تتم معالجته، ويمكنك تحديد البيانات التي ستصل إليها والعمليات التي ستنفّذها من هناك.
يجب إنشاء برامج تظليل الحساب في وحدة برنامج تظليل، تمامًا مثل برامج تظليل الرأس والكسر، لذا أضِف ذلك إلى الرمز البرمجي لبدء الاستخدام. كما قد تتوقّع، بالنظر إلى بنية برامج التظليل الأخرى التي نفّذتها، يجب وضع علامة @compute
على الدالة الرئيسية لبرنامج التظليل الحسابي.
- أنشئ برنامج تظليل حسابيًا باستخدام الرمز التالي:
index.html
// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
label: "Game of Life simulation shader",
code: `
@compute
fn computeMain() {
}`
});
بما أنّ وحدات معالجة الرسومات تُستخدَم بشكل متكرّر للرسومات الثلاثية الأبعاد، يتم تنظيم برامج التظليل الحسابية بطريقة تتيح لك طلب استدعاء برنامج التظليل عددًا محدّدًا من المرات على طول المحاور X وY وZ. يتيح لك ذلك إرسال المهام التي تتوافق مع شبكة ثنائية أو ثلاثية الأبعاد بسهولة بالغة، وهو أمر رائع لحالة الاستخدام الخاصة بك. نريد استدعاء برنامج التظليل هذا GRID_SIZE
مرة GRID_SIZE
مرة، أي مرة واحدة لكل خلية من المحاكاة.
نظرًا لطبيعة بنية أجهزة وحدة معالجة الرسومات، يتم تقسيم هذه الشبكة إلى مجموعات عمل. تحتوي مجموعة العمل على حجم X وY وZ، وعلى الرغم من أنّ الأحجام يمكن أن تكون 1 لكل منها، إلا أنّه غالبًا ما تكون هناك مزايا في الأداء عند تكبير مجموعات العمل قليلاً. بالنسبة إلى برنامج التظليل، اختَر حجم مجموعة عمل عشوائيًا إلى حد ما يبلغ 8 × 8. ويكون ذلك مفيدًا لتتبُّعها في رمز JavaScript.
- حدِّد قيمة ثابتة لحجم مجموعة العمل، على النحو التالي:
index.html
const WORKGROUP_SIZE = 8;
عليك أيضًا إضافة حجم مجموعة العمل إلى دالة التظليل نفسها، وذلك باستخدام القيم الحرفية للنماذج في JavaScript حتى تتمكّن من استخدام الثابت الذي حدّدته للتو بسهولة.
- أضِف حجم مجموعة العمل إلى دالة التظليل، على النحو التالي:
index.html (Compute createShaderModule call)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
يخبر هذا الرمز برنامج تظليل الرسومات بأنّ العمل المنجز باستخدام هذه الدالة يتم في مجموعات (8 × 8 × 1). (يتم تلقائيًا ضبط أي محور لا تحدّده على 1، ولكن عليك تحديد المحور X على الأقل).
كما هو الحال مع مراحل التظليل الأخرى، هناك مجموعة متنوعة من قيم @builtin
التي يمكنك قبولها كمدخلات في دالة تظليل الحساب من أجل معرفة الاستدعاء الذي تستخدمه وتحديد العمل الذي عليك تنفيذه.
- أضِف قيمة
@builtin
، على النحو التالي:
index.html (Compute createShaderModule call)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
يمكنك إدخال global_invocation_id
المضمّن، وهو متجه ثلاثي الأبعاد من الأعداد الصحيحة غير الموقّعة يخبرك بمكانك في شبكة استدعاءات برنامج التظليل. يتم تنفيذ برنامج التظليل هذا مرة واحدة لكل خلية في شبكتك. ستحصل على أرقام مثل (0, 0, 0)
و(1, 0, 0)
و(1, 1, 0)
... وصولاً إلى (31, 31, 0)
، ما يعني أنّه يمكنك التعامل معها كفهرس الخلية التي ستعمل عليها.
يمكن أن تستخدم برامج التظليل الحسابية أيضًا متغيرات موحّدة، ويمكنك استخدامها تمامًا كما في برامج تظليل الرؤوس وبرامج تظليل الأجزاء.
- استخدِم متغيرًا موحّدًا مع برنامج التظليل الحسابي لإخبارك بحجم الشبكة، على النحو التالي:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f; // New line
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
كما هو الحال في برنامج تظليل الرؤوس، يمكنك أيضًا عرض حالة الخلية كمخزن مؤقت للتخزين. ولكن في هذه الحالة، تحتاج إلى اثنين منهما. بما أنّ برامج التظليل الحسابية لا تتطلّب إخراجًا، مثل موضع الرأس أو لون الجزء، فإنّ كتابة القيم في مخزن مؤقت أو نسيج هو الطريقة الوحيدة للحصول على نتائج من برنامج تظليل حسابي. استخدِم طريقة "بينغ بونغ" التي تعلّمتها سابقًا، إذ لديك مخزن مؤقت واحد يخزّن الحالة الحالية للشبكة وآخر تكتب فيه الحالة الجديدة للشبكة.
- اعرض حالة إدخال الخلية وإخراجها كمخازن مؤقتة للتخزين، على النحو التالي:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
يُرجى العِلم أنّه يتم تعريف مخزن البيانات الأول باستخدام var<storage>
، ما يجعله للقراءة فقط، ولكن يتم تعريف مخزن البيانات الثاني باستخدام var<storage, read_write>
. يتيح لك ذلك قراءة المخزن المؤقت والكتابة فيه، واستخدامه كناتج لبرنامج التظليل الحسابي. (لا يتوفّر وضع تخزين للقراءة فقط في WebGPU).
بعد ذلك، يجب أن تتوفّر لديك طريقة لربط فهرس الخلية بمصفوفة التخزين الخطية. هذا هو عكس ما فعلته في برنامج تظليل الرؤوس، حيث أخذت instance_index
الخطي وربطته بخلية شبكة ثنائية الأبعاد. (للتذكير، كانت الخوارزمية التي استخدمتها لذلك هي vec2f(i % grid.x, floor(i / grid.x))
).
- اكتب دالة للانتقال في الاتجاه الآخر. تأخذ قيمة Y للخلية، وتضربها في عرض الشبكة، ثم تضيف قيمة X للخلية.
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
// New function
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
وأخيرًا، للتأكّد من أنّها تعمل، نفِّذ خوارزمية بسيطة للغاية: إذا كانت الخلية نشطة حاليًا، سيتم إيقافها، والعكس صحيح. هذا ليس "لعبة الحياة" بعد، ولكنّه يكفي لإظهار أنّ برنامج التظليل الحسابي يعمل.
- أضِف الخوارزمية البسيطة، كما يلي:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines. Flip the cell state every step.
if (cellStateIn[cellIndex(cell.xy)] == 1) {
cellStateOut[cellIndex(cell.xy)] = 0;
} else {
cellStateOut[cellIndex(cell.xy)] = 1;
}
}
هذا كل ما يتعلق ببرنامج تظليل الحوسبة في الوقت الحالي. ولكن قبل أن تتمكّن من رؤية النتائج، عليك إجراء بعض التغييرات الإضافية.
استخدام تنسيقات مجموعة الربط وتنسيقات خطوط الإنتاج
من الملاحظات التي قد تلاحظها من برنامج التظليل أعلاه أنّه يستخدم إلى حد كبير المدخلات نفسها (المتغيرات الموحّدة ومخازن البيانات) مثل مسار العرض. لذا، قد تعتقد أنّه يمكنك ببساطة استخدام مجموعات الربط نفسها والانتهاء من ذلك، أليس كذلك؟ والخبر السار هو أنّه يمكنك ذلك. ما عليك سوى إجراء بعض خطوات الإعداد اليدوي الإضافية لتتمكّن من ذلك.
في كل مرة تنشئ فيها مجموعة ربط، عليك تقديم GPUBindGroupLayout
. في السابق، كان بإمكانك الحصول على هذا التصميم من خلال استدعاء getBindGroupLayout()
في مسار العرض، ما كان يؤدي بدوره إلى إنشائه تلقائيًا لأنّك قدّمت layout: "auto"
عند إنشائه. يعمل هذا الأسلوب بشكل جيد عند استخدام مسار معالجة واحد فقط، ولكن إذا كان لديك مسارات معالجة متعددة تريد مشاركة الموارد، عليك إنشاء التصميم بشكل صريح، ثم توفيره لكل من مجموعة الربط ومسارات المعالجة.
للمساعدة في فهم السبب، ضع في اعتبارك ما يلي: في مسارات العرض، يمكنك استخدام مخزن مؤقت موحّد واحد ومخزن مؤقت واحد، ولكن في برنامج التظليل الحسابي الذي كتبته للتو، تحتاج إلى مخزن مؤقت ثانٍ. بما أنّ برنامجَي التظليل يستخدمان قيم @binding
نفسها للمخزن الموحّد ومخزن التخزين الأول، يمكنك مشاركة هذه القيم بين خطوط الأنابيب، ويتجاهل خط أنابيب العرض مخزن التخزين الثاني الذي لا يستخدمه. أنت تريد إنشاء تخطيط يصف جميع الموارد المتوفرة في مجموعة الربط، وليس فقط تلك التي تستخدمها عملية عرض محدّدة.
- لإنشاء هذا التنسيق، استخدِم الرمز
device.createBindGroupLayout()
:
index.html
// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
label: "Cell Bind Group Layout",
entries: [{
binding: 0,
// Add GPUShaderStage.FRAGMENT here if you are using the `grid` uniform in the fragment shader.
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: {} // Grid uniform buffer
}, {
binding: 1,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: { type: "read-only-storage"} // Cell state input buffer
}, {
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "storage"} // Cell state output buffer
}]
});
يشبه ذلك في البنية إنشاء مجموعة الربط نفسها، حيث تصف قائمة entries
. والفرق هو أنّك تصف نوع المرجع وكيفية استخدامه بدلاً من تقديم المرجع نفسه.
في كل إدخال، عليك تقديم رقم binding
للمورد، والذي يتطابق (كما تعلّمت عند إنشاء مجموعة الربط) مع قيمة @binding
في برامج التظليل. يمكنك أيضًا تقديم visibility
، وهي علامات GPUShaderStage
تشير إلى مراحل التظليل التي يمكنها استخدام المورد. تريد أن يكون كل من المخزن المؤقت الموحّد والمخزن المؤقت الأول متاحَين في برامج تظليل الرؤوس وبرامج تظليل الحوسبة، ولكن يجب أن يكون المخزن المؤقت الثاني متاحًا في برامج تظليل الحوسبة فقط.
أخيرًا، عليك تحديد نوع المورد المستخدَم. هذا مفتاح قاموس مختلف، حسب ما تحتاج إلى عرضه. في هذا المثال، جميع الموارد الثلاثة هي مخازن مؤقتة، لذا يمكنك استخدام المفتاح buffer
لتحديد الخيارات لكل منها. تشمل الخيارات الأخرى أشياء مثل texture
أو sampler
، ولكنّك لست بحاجة إليها هنا.
في قاموس المخزن المؤقت، يمكنك ضبط خيارات مثل نوع المخزن المؤقت type
المُستخدَم. القيمة التلقائية هي "uniform"
، لذا يمكنك ترك القاموس فارغًا لربط القيمة 0. (يجب ضبط القيمة buffer: {}
على الأقل حتى يتم تحديد الإدخال كمخزن مؤقت). تم منح الرابط 1 النوع "read-only-storage"
لأنّك لا تستخدمه مع إذن الوصول read_write
في برنامج التظليل، وتم منح الرابط 2 النوع "storage"
لأنّك تستخدمه مع إذن الوصول read_write
.
بعد إنشاء bindGroupLayout
، يمكنك تمريره عند إنشاء مجموعات الربط بدلاً من طلب مجموعة الربط من مسار العرض. ويعني ذلك أنّه عليك إضافة إدخال جديد لمخزن مؤقت للتخزين إلى كل مجموعة ربط من أجل مطابقة التصميم الذي حدّدته للتو.
- عدِّل عملية إنشاء مجموعة الربط على النحو التالي:
index.html
// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[1] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[0] }
}],
}),
];
وبعد تعديل مجموعة الربط لاستخدام تخطيط مجموعة الربط الصريح هذا، عليك تعديل مسار العرض لاستخدام الشيء نفسه.
- أنشئ
GPUPipelineLayout
.
index.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
تخطيط خط الأنابيب هو قائمة بتصاميم مجموعات الربط (في هذه الحالة، لديك تصميم واحد) تستخدمه عملية واحدة أو أكثر. يجب أن يتطابق ترتيب تنسيقات مجموعة الربط في المصفوفة مع سمات @group
في برامج التظليل. (هذا يعني أنّ bindGroupLayout
مرتبط بـ @group(0)
).
- بعد الحصول على تخطيط مسار العرض، عدِّل مسار العرض لاستخدامه بدلاً من
"auto"
.
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: pipelineLayout, // Updated!
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
إنشاء مسار الحوسبة
كما تحتاج إلى مسار عرض لاستخدام برامج تظليل الرؤوس والكسور، تحتاج أيضًا إلى مسار حساب لاستخدام برنامج تظليل الحساب. لحسن الحظ، إنّ خطوط أنابيب الحوسبة أقل تعقيدًا من خطوط أنابيب العرض، إذ لا تتضمّن أي حالة يجب ضبطها، بل تتضمّن فقط برنامج التظليل والتصميم.
- أنشئ مسارًا حسابيًا باستخدام الرمز التالي:
index.html
// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
label: "Simulation pipeline",
layout: pipelineLayout,
compute: {
module: simulationShaderModule,
entryPoint: "computeMain",
}
});
لاحظ أنّك تمرِّر pipelineLayout
الجديد بدلاً من "auto"
، تمامًا كما هو الحال في عملية العرض المعدَّلة، ما يضمن إمكانية استخدام مجموعات الربط نفسها في كلّ من عملية العرض وعملية الحساب.
بطاقات Compute
سيؤدي ذلك إلى الانتقال إلى نقطة الاستفادة الفعلية من مسار الحوسبة. بما أنّ عملية العرض تتم في عملية عرض، يمكنك على الأرجح تخمين أنّك بحاجة إلى تنفيذ عملية حسابية في عملية حسابية. يمكن أن تحدث كل من عمليات الحساب والعرض في أداة ترميز الأوامر نفسها، لذا عليك إعادة ترتيب وظيفة updateGrid
قليلاً.
- انقل عملية إنشاء برنامج الترميز إلى أعلى الدالة، ثم ابدأ عملية حسابية باستخدامها (قبل
step++
).
index.html
// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();
const computePass = encoder.beginComputePass();
// Compute work will go here...
computePass.end();
// Existing lines
step++; // Increment the step count
// Start a render pass...
وكما هو الحال مع خطوط أنابيب العمليات الحسابية، فإنّ عمليات الحساب أبسط بكثير من عمليات العرض المقابلة لها لأنّك لست بحاجة إلى القلق بشأن أي مرفقات.
يجب تنفيذ عملية الحساب قبل عملية العرض لأنّ ذلك يسمح لعملية العرض باستخدام أحدث النتائج من عملية الحساب على الفور. وهذا هو أيضًا السبب الذي يجعلك تزيد عدد step
بين عمليات التمرير، حتى يصبح المخزن المؤقت الناتج لخط أنابيب الحساب هو المخزن المؤقت للإدخال لخط أنابيب العرض.
- بعد ذلك، اضبط مسار العرض ومجموعة الربط داخل عملية الحساب، باستخدام النمط نفسه للتبديل بين مجموعات الربط كما تفعل في عملية العرض.
index.html
const computePass = encoder.beginComputePass();
// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
computePass.end();
- أخيرًا، بدلاً من الرسم كما في عملية العرض، يمكنك إرسال العمل إلى برنامج التظليل الحسابي، وإخباره بعدد مجموعات العمل التي تريد تنفيذها على كل محور.
index.html
const computePass = encoder.beginComputePass();
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);
computePass.end();
ملاحظة مهمة جدًا: الرقم الذي يتم تمريره إلى dispatchWorkgroups()
ليس عدد عمليات الاستدعاء. بدلاً من ذلك، يمثّل هذا العدد عدد مجموعات العمل التي سيتم تنفيذها، كما هو محدّد بواسطة @workgroup_size
في برنامج التظليل.
إذا أردت تنفيذ برنامج التظليل 32×32 مرة لتغطية الشبكة بأكملها، وكان حجم مجموعة العمل 8×8، عليك إرسال مجموعات عمل 4×4 (4 * 8 = 32). لهذا السبب، عليك تقسيم حجم الشبكة على حجم مجموعة العمل وتمرير هذه القيمة إلى dispatchWorkgroups()
.
يمكنك الآن إعادة تحميل الصفحة مرة أخرى، ومن المفترض أن ترى أنّ الشبكة تعكس نفسها مع كل تعديل.
تنفيذ الخوارزمية الخاصة بلعبة الحياة
قبل تعديل برنامج التظليل الحسابي لتنفيذ الخوارزمية النهائية، عليك الرجوع إلى الرمز الذي يهيئ محتوى مخزن البيانات وتعديله لإنشاء مخزن عشوائي في كل مرة يتم فيها تحميل الصفحة. (لا تشكّل الأنماط المنتظمة نقاط بداية مثيرة للاهتمام في لعبة الحياة). يمكنك عشوائية القيم بالطريقة التي تريدها، ولكن هناك طريقة سهلة للبدء تعطي نتائج معقولة.
- لبدء كل خلية بحالة عشوائية، عدِّل عملية تهيئة
cellStateArray
إلى الرمز التالي:
index.html
// Set each cell to a random state, then copy the JavaScript array
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
يمكنك الآن أخيرًا تنفيذ منطق محاكاة "لعبة الحياة". بعد كل ما تطلّب الوصول إلى هنا، قد يكون رمز التظليل بسيطًا بشكل مخيّب للآمال.
أولاً، عليك معرفة عدد الخلايا المجاورة النشطة لأي خلية معيّنة. لا يهمّك أيّها نشط، بل يهمّك العدد فقط.
- لتسهيل الحصول على بيانات الخلايا المجاورة، أضِف الدالة
cellActive
التي تعرض قيمةcellStateIn
للإحداثيات المحدّدة.
index.html (Compute createShaderModule call)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
تعرض الدالة cellActive
القيمة 1 إذا كانت الخلية نشطة، لذا فإنّ إضافة القيمة المعروضة من استدعاء الدالة cellActive
لجميع الخلايا الثماني المحيطة يمنحك عدد الخلايا المجاورة النشطة.
- ابحث عن عدد الجيران النشطين، على النحو التالي:
index.html (Compute createShaderModule call)
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines:
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
يؤدي ذلك إلى مشكلة بسيطة، وهي: ماذا يحدث عندما تكون الخلية التي تتحقّق منها خارج حافة اللوحة؟ وفقًا لمنطق cellIndex()
الحالي، إما أن يفيض إلى الصف التالي أو السابق، أو يخرج من حافة المخزن المؤقت.
بالنسبة إلى لعبة الحياة، تتمثّل إحدى الطرق الشائعة والسهلة لحلّ هذه المشكلة في أن تتعامل الخلايا الموجودة على حافة الشبكة مع الخلايا الموجودة على الحافة المقابلة للشبكة على أنّها الخلايا المجاورة لها، ما يؤدي إلى إنشاء نوع من التأثير الشامل.
- إتاحة التفاف الشبكة مع إجراء تغيير بسيط على الدالة
cellIndex()
index.html (Compute createShaderModule call)
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
باستخدام عامل التشغيل %
لتضمين الخليتين X وY عندما تتجاوزان حجم الشبكة، يمكنك التأكّد من عدم الوصول إلى خارج حدود مخزن البيانات المؤقت. وبذلك، يمكنك الاطمئنان إلى أنّ عدد activeNeighbors
يمكن توقّعه.
بعد ذلك، يمكنك تطبيق إحدى القواعد الأربع التالية:
- تصبح أي خلية لديها أقل من خليتين مجاورتين غير نشطة.
- تظل أي خلية نشطة لها خليتان أو ثلاث خلايا مجاورة نشطة.
- يصبح أي خلية غير نشطة تضمّ ثلاثة خلايا مجاورة نشطة.
- تصبح أي خلية تحتوي على أكثر من ثلاث خلايا مجاورة غير نشطة.
يمكنك إجراء ذلك باستخدام سلسلة من عبارات if، ولكن تتوافق WGSL أيضًا مع عبارات switch، وهي مناسبة لهذا المنطق.
- نفِّذ منطق "لعبة الحياة"، على النحو التالي:
index.html (Compute createShaderModule call)
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: { // Active cells with 2 neighbors stay active.
cellStateOut[i] = cellStateIn[i];
}
case 3: { // Cells with 3 neighbors become or stay active.
cellStateOut[i] = 1;
}
default: { // Cells with < 2 or > 3 neighbors become inactive.
cellStateOut[i] = 0;
}
}
للعلم، يبدو استدعاء وحدة تظليل الحساب النهائية الآن على النحو التالي:
index.html
const simulationShaderModule = device.createShaderModule({
label: "Life simulation shader",
code: `
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: {
cellStateOut[i] = cellStateIn[i];
}
case 3: {
cellStateOut[i] = 1;
}
default: {
cellStateOut[i] = 0;
}
}
}
`
});
وهذا كل ما في الأمر. لقد أنهيت عملك! أعِد تحميل الصفحة وشاهِد الأوتوماتا الخلوية التي أنشأتها تنمو!
9. تهانينا!
لقد أنشأت إصدارًا من محاكاة "لعبة الحياة" الكلاسيكية من ابتكار كونواي يعمل بالكامل على وحدة معالجة الرسومات باستخدام WebGPU API.
ما هي الخطوات التالية؟
- مراجعة أمثلة WebGPU