أول تطبيق WebGPU

1. مقدمة

يتكوّن شعار WebGPU من عدة مثلثات زرقاء تشكل الحرف "W" المصمّم على شكل حرف "W".

ما هي WebGPU؟

WebGPU هي واجهة برمجة تطبيقات جديدة وحديثة تتيح لك الاستفادة من إمكانات وحدة معالجة الرسومات في تطبيقات الويب.

واجهة برمجة التطبيقات الحديثة

قبل استخدام WebGPU، كان هناك WebGL الذي قدَّم مجموعة فرعية من ميزات WebGPU. أتاح ذلك فئة جديدة من محتوى الويب الوافي، وأنشأ مطوّرو البرامج باستخدامه أشياء رائعة. مع ذلك، استندت إلى واجهة برمجة التطبيقات OpenGL ES 2.0 التي تم إصدارها في عام 2007، والتي كانت تستند إلى واجهة برمجة تطبيقات OpenGL API أقدم. وقد تطوّرت وحدات معالجة الرسومات بشكلٍ كبير في تلك الفترة، وتطوَّرت أيضًا واجهات برمجة التطبيقات الأصلية المستخدَمة للتعامل معها، بالإضافة إلى Direct3D 12 وMetal وVulkan.

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

العرض

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

الحوسبة

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

في هذا الدرس التطبيقي حول الترميز، ستتعلم كيفية الاستفادة من إمكانات العرض والحوسبة في WebGPU لإنشاء مشروع تمهيدي بسيط.

ما الذي ستنشئه

في هذا الدرس التطبيقي حول الترميز، يمكنك إنشاء Conway's Game of Life باستخدام WebGPU. سينفّذ تطبيقك ما يلي:

  • استخدِم إمكانات العرض في WebGPU لرسم رسومات ثنائية الأبعاد بسيطة.
  • استخدِم إمكانات الحوسبة في WebGPU لتنفيذ المحاكاة.

لقطة شاشة للمنتج النهائي في هذا الدرس التطبيقي حول الترميز

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

المعلومات التي ستطّلع عليها

  • كيفية إعداد WebGPU وإعداد لوحة.
  • كيفية رسم هندسة بسيطة ثنائية الأبعاد.
  • كيفية استخدام أدوات تظليل رأس الأجزاء وأجزاء الرأس لتعديل ما يتم رسمه.
  • كيفية استخدام أدوات التظليل الحوسبية لإجراء محاكاة بسيطة.

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

المتطلبات

  • إصدار حديث من Chrome (الإصدار 113 أو إصدار أحدث) على نظام التشغيل ChromeOS أو macOS أو Windows WebGPU هي واجهة برمجة تطبيقات تعمل على عدة متصفِّحات وتعمل على الأنظمة الأساسية، ولكن لم يتم شحنها إلى كل المنصات حتى الآن.
  • معرفة HTML وJavaScript وأدوات مطوري البرامج في Chrome

لا يُشترط الإلمام بواجهات برمجة تطبيقات الرسومات الأخرى، مثل WebGL أو Metal أو Vulkan أو Direct3D، ولكن إذا كانت لديك أي خبرة في استخدامها، ستلاحظ على الأرجح الكثير من أوجه التشابه مع WebGPU التي قد تساعدك في بدء التعلُّم على الفور.

2. الإعداد

الحصول على الرمز

لا يحتوي هذا الدرس التطبيقي على أي تبعيات، حيث يرشدك إلى كل خطوة مطلوبة لإنشاء تطبيق WebGPU، وبذلك لا تحتاج إلى أي رمز للبدء. ومع ذلك، تتوفّر بعض الأمثلة العملية التي يمكن استخدامها كنقاط فحص على https://glitch.com/edit/#!/your-first-webgpu-app. يمكنك الاطّلاع عليها والإشارة إليها أثناء التنقّل إذا واجهتك مشكلة.

استخدام وحدة تحكّم المطوّرين

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

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

من المفيد دائمًا إبقاء وحدة التحكّم مفتوحة أثناء العمل على أي تطبيق ويب، ولكن ينطبق ذلك بشكل خاص هنا.

3- إعداد WebGPU

البدء بـ <canvas>

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

يمكنك إنشاء مستند HTML جديد يحتوي على عنصر <canvas> واحد بالإضافة إلى علامة <script> لطلب البحث عن عنصر لوحة الرسم. (أو استخدِم 00- Starter-page.html من glitch).

  • أنشئ ملف 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.

  1. للتأكّد من توفّر العنصر navigator.gpu، الذي يُعدّ نقطة دخول إلى WebGPU، أضِف الرمز التالي:

index.html

if (!navigator.gpu) {
  throw new Error("WebGPU not supported on this browser.");
}

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

بعد التأكّد من أنّ المتصفّح متوافق مع واجهة برمجة التطبيقات WebGPU، تكون الخطوة الأولى لإعداد WebGPU لتطبيقك هي طلب GPUAdapter. يمكنك اعتبار المحوّل بمثابة تمثيل WebGPU لجزء معيّن من معدّات وحدة معالجة الرسومات في جهازك.

  1. وللحصول على محوّل، استخدِم الطريقة navigator.gpu.requestAdapter(). يعرض لك وعدًا، لذا من الأسهل طلبه باستخدام await.

index.html

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("No appropriate GPUAdapter found.");
}

وإذا لم يتم العثور على محوّلات مناسبة، قد تكون قيمة adapter التي تم عرضها هي null، لذا عليك معالجة هذا الاحتمال. قد يحدث ذلك إذا كان متصفّح المستخدم متوافقًا مع WebGPU ولكن جهاز وحدة معالجة الرسومات لا يتضمّن جميع الميزات اللازمة لاستخدام WebGPU.

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

بمجرد حصولك على محوّل، تكون الخطوة الأخيرة قبل بدء العمل مع وحدة معالجة الرسومات هي طلب GPUDevice. الجهاز هو الواجهة الرئيسية التي يحدث من خلالها معظم التفاعلات مع وحدة معالجة الرسومات.

  1. يمكنك الحصول على الجهاز من خلال الاتصال بالرقم adapter.requestDevice()، ما يؤدي أيضًا إلى إرجاع وعدك بذلك.

index.html

const device = await adapter.requestDevice();

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

ضبط "لوحة الرسم"

والآن بعد أن حصلت على جهاز، هناك إجراء آخر يمكنك فعله إذا كنت تريد استخدامه لعرض أي شيء على الصفحة: وهو إعداد لوحة الرسم لتتمكن من استخدامها مع الجهاز الذي أنشأته للتو.

  • لإجراء ذلك، عليك أولاً طلب GPUCanvasContext من اللوحة من خلال الاتصال بالرقم canvas.getContext("webgpu"). (هذا هو الاستدعاء نفسه الذي تستخدمه لإعداد سياقات لوحة الرسم ثنائية الأبعاد أو 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 لتخزين بيانات الصور، ولكل زخرفة تنسيق يتيح لوحدة GPU معرفة كيفية ترتيب هذه البيانات في الذاكرة. لا تندرج تفاصيل طريقة عمل ذاكرة المظهر الخارجي ضمن هذا الدرس التطبيقي حول الترميز. الشيء المهم الذي يجب معرفته هو أن سياق لوحة الرسم يوفر زخارف لرسم التعليمات البرمجية الخاصة بك، ويمكن أن يكون للتنسيق الذي تستخدمه تأثير على مدى كفاءة لوحة الرسم في عرض تلك الصور. تعمل الأنواع المختلفة من الأجهزة بشكل أفضل عند استخدام تنسيقات زخرفة مختلفة، وإذا لم تستخدم التنسيق المفضل للجهاز، فقد يتسبب ذلك في ظهور نسخ إضافية من الذاكرة وراء الكواليس قبل عرض الصورة كجزء من الصفحة.

لحسن الحظ، لا داعي للقلق بشأن أي من هذه المشاكل لأنّ WebGPU تخبرك بالتنسيق الذي يجب استخدامه في لوحة الرسم. في جميع الحالات تقريبًا، تريد تمرير القيمة المعروضة من خلال استدعاء navigator.gpu.getPreferredCanvasFormat()، كما هو موضح أعلاه.

محو لوحة الرسم

الآن بعد أن أصبح لديك جهاز وتم ضبط اللوحة عليه، يمكنك بدء استخدام الجهاز لتغيير محتوى اللوحة. للبدء، عليك محوها بلون واحد.

ولتنفيذ ذلك، أو أي شيء آخر في WebGPU، تحتاج إلى تقديم بعض الأوامر إلى وحدة معالجة الرسومات (GPU) لتوجِّهها إلى ما يجب فعله.

  1. لإجراء ذلك، اطلب من الجهاز إنشاء GPUCommandEncoder، توفّر واجهة لتسجيل أوامر وحدة معالجة الرسومات.

index.html

const encoder = device.createCommandEncoder();

ترتبط الأوامر التي تريد إرسالها إلى وحدة معالجة الرسومات بالعرض (في هذه الحالة، محو اللوحة)، لذا فإن الخطوة التالية هي استخدام encoder لبدء Render Pass.

بطاقات العرض هي عند حدوث كل عمليات الرسم في WebGPU. ويبدأ كل عنصر باستدعاء beginRenderPass() الذي يحدِّد الزخارف التي تتلقّى نتيجة أي أوامر رسم يتم تنفيذها. ويمكن أن توفر الاستخدامات الأكثر تقدمًا عدة زخارف تُعرف باسم المرفقات، ولها أغراض عديدة مثل تخزين التفاصيل الهندسية المعروضة أو إضافة تنعيمها. ومع ذلك، بالنسبة إلى هذا التطبيق، لن تحتاج سوى إلى أداة واحدة.

  1. احصل على الهيئة من سياق اللوحة الذي أنشأته سابقًا من خلال استدعاء 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" لمحو عرض الزخرفة ولوحة الرسم.

  1. يمكنك إنهاء تصريح العرض من خلال إضافة المكالمة التالية بعد beginRenderPass() مباشرةً:

index.html

pass.end();

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

  1. لإنشاء GPUCommandBuffer، يُرجى طلب الرقم finish() في برنامج ترميز الأوامر. المخزن المؤقت للأوامر هو مؤشر مبهم للأوامر المسجلة.

index.html

const commandBuffer = encoder.finish();
  1. أرسِل المخزن المؤقت للأوامر إلى وحدة معالجة الرسومات باستخدام 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() مرة أخرى للحصول على زخرفة جديدة لبطاقة العرض.

  1. إعادة تحميل الصفحة لاحظ أن اللوحة مملوءة باللون الأسود. تهانينا وهذا يعني أنك أنشأت بنجاح تطبيق WebGPU الأول بنجاح.

لوحة سوداء تشير إلى أنّه تم استخدام WebGPU بنجاح لمحو محتوى لوحة الرسم.

اختيار لون

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

  1. في مكالمة 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 } هو اللون الأسود الشفاف تلقائيًا.

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

  1. بعد اختيار اللون، أعِد تحميل الصفحة. من المفترض أن يظهر لك اللون الذي اخترته في اللوحة.

تم مسح لوحة الرسم إلى اللون الأزرق الداكن لتوضيح كيفية تغيير اللون الواضح الافتراضي.

4. رسمُ الأشكال الهندسية

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

التعرّف على طريقة رسم وحدات معالجة الرسومات

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

على عكس واجهة برمجة التطبيقات مثل Canvas 2D التي تحتوي على العديد من الأشكال والخيارات الجاهزة للاستخدام، تتعامل وحدة معالجة الرسومات مع بضعة أنواع مختلفة من الأشكال (أو الأجزاء الأساسية كما يُشار إليها في WebGPU): النقاط والخطوط والمثلثات. لأغراض هذا الدرس التطبيقي، ستستخدم المثلثات فقط.

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

تتوفر هذه النقاط، أو الرؤوس، وفقًا للقيم X وY و (للمحتوى الثلاثي الأبعاد) التي تحدد نقطة على نظام الإحداثيات كارتيزية التي تم تحديدها بواسطة WebGPU أو واجهات برمجة تطبيقات مشابهة. من الأسهل التفكير في هيكل نظام الإحداثيات من حيث كيفية ارتباطه بلوحة الرسم على صفحتك. بغض النظر عن عرض لوحة الرسم أو طولها، تكون الحافة اليسرى دائمًا عند -1 على المحور "س"، وتكون الحافة اليمنى دائمًا عند +1 على المحور "س". وبالمثل، تكون الحافة السفلية دائمًا -1 على المحور ص، والحافة العلوية هي +1 على المحور ص. هذا يعني أن (0، 0) هو دائمًا مركز اللوحة، (-1، -1) هو دائمًا الزاوية السفلية اليسرى، و (1، 1) هو دائمًا أعلى الجانب الأيمن. وتُعرف هذه المساحة باسم مساحة المقطع.

رسم بياني بسيط يعرض مساحة إحداثية الأجهزة التي تمت تسويتها

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

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

ثم يتم تجميع نتائج ألوان البكسل هذه في زخرفة، والتي يمكن بعد ذلك عرضها على الشاشة.

تحديد الرؤوس

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

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

رسم بياني لإحداثيات الجهاز التي تمت تسويتها يعرض إحداثيات زوايا المربع

لتزويد وحدة معالجة الرسومات بهذه الإحداثيات، يجب وضع القيم في TypedArray. إذا لم تكن معتادًا على استخدامها، فإن TypedArrays هي مجموعة من كائنات JavaScript التي تتيح لك تخصيص كتل متجاورة من الذاكرة وتفسير كل عنصر في السلسلة كنوع بيانات محدد. على سبيل المثال، في Uint8Array، يكون كل عنصر في المصفوفة عبارة عن بايت واحد غير موقَّع. تُعد TypedArrays رائعةً لإرسال البيانات باستمرار باستخدام واجهات برمجة التطبيقات الحساسة لتخطيط الذاكرة، مثل WebAssembly وWebAudio و (بالطبع) WebGPU.

بالنسبة إلى مثال المربع، نظرًا لأن القيم كسرية، فإن استخدام Float32Array مناسب.

  1. أنشئ صفيفًا يحتوي على جميع مواضع الرأس في الرسم التخطيطي عن طريق وضع إعلان الصفيف التالي في التعليمة البرمجية. المكان المناسب لوضعه بالقرب من الأعلى، أسفل المكالمة 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,
]);

لاحظ أن التباعد والتعليق ليس لهما أي تأثير على القيم، بل الهدف منها تيسير الأمر عليك وتجعله أكثر سهولة في القراءة. يساعدك على رؤية أن كل زوج من القيم يشكل إحداثيات X وY لرأس واحد.

ولكن هناك مشكلة! عمل وحدات معالجة الرسومات بدلاً من المثلثات، أليس كذلك؟ ويعني ذلك أن عليك وضع الرؤوس في مجموعات مكونة من ثلاثة رؤوس. لَدَيْنَا مَجْمُوعَة وَاحِدَة وَأَرْبَعَة. الحل هو تكرار رأسين من رؤوسهما لإنشاء مثلثين يتشاركان في حافة في منتصف المربع.

مخطّط بياني يوضّح كيفية استخدام الرؤوس الأربعة للمربّع لتشكيل مثلثَين

لتشكيل المربع من المخطط، يجب عليك سرد رؤوس (-0.8، -0.8) و (0.8، 0.8) مرتين، مرة للمثلث الأزرق ومرة للمثلث الأحمر. (يمكنك أيضًا اختيار تقسيم المربع مع الزاويتين الأخريين بدلاً من ذلك؛ فهذا لا يحدث أي فرق).

  1. عدِّل مصفوفة 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 المرئية لوحدة معالجة الرسومات.

  1. لإنشاء مخزن مؤقت للاحتفاظ بالرؤوس، أضِف الاستدعاء التالي إلى device.createBuffer() بعد تعريف المصفوفة vertices.

index.html

const vertexBuffer = device.createBuffer({
  label: "Cell vertices",
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

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

بعد ذلك، حدِّد size للمخزن المؤقت بالبايت. يجب أن يتوفّر لديك مخزن مؤقت بحجم 48 بايت، ويمكنك تحديده من خلال ضرب حجم وحدة عائمة 32 بت ( 4 بايت) في عدد الأعداد العشرية في المصفوفة vertices (12). ولحسن الحظ، تحسب TypedArrays قيمة byteLength لك، وبالتالي يمكنك استخدام ذلك عند إنشاء المخزن المؤقت.

وأخيرًا، عليك تحديد استخدام المخزن المؤقت. هذه علامة واحدة أو أكثر من علامات GPUBufferUsage، مع دمج علامات متعددة مع عامل التشغيل | ( باتجاه البت OR). في هذه الحالة، يجب أن تختار استخدام المخزن المؤقت لبيانات رأس الصفحة (GPUBufferUsage.VERTEX) وأن تكون قادرًا أيضًا على نسخ البيانات إليه (GPUBufferUsage.COPY_DST).

كائن المخزن المؤقت الذي يتم إرجاعه إليك معتم - لا يمكنك (بسهولة) فحص البيانات التي يحتفظ بها. بالإضافة إلى ذلك، معظم سماتها غير قابلة للتغيير، ولا يمكنك تغيير حجم GPUBuffer بعد إنشائه، كما لا يمكنك تغيير علامات الاستخدام. وما يمكنك تغييره هو محتوى ذاكرته.

عند إنشاء المخزن المؤقت في البداية، سيتم تهيئة الذاكرة التي يحتوي عليها على صفر. هناك عدة طرق لتغيير محتوى المحادثة، ولكن الطريقة الأسهل هي طلب device.queue.writeBuffer() باستخدام TypedArray الذي تريد نسخه.

  1. لنسخ بيانات الرأس إلى ذاكرة المخزن المؤقت، أضف الرمز التالي:

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 بايت يكون 8 بايت.

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

في السمة الفردية، عليك أولاً تحديد format للبيانات. ويرجع ذلك إلى قائمة من أنواع GPUVertexFormat التي تصف كل نوع من بيانات رأس الصفحة التي يمكن أن تفهمها وحدة معالجة الرسومات. ويحتوي كل رأس على عائمَين عائمَين 32 بت، لذا يجب استخدام التنسيق float32x2. إذا كانت بيانات رأسك تتكون من أربعة أعداد صحيحة غير موقّعة 16 بت لكل منها، على سبيل المثال، يمكنك استخدام uint16x4 بدلاً من ذلك. هل ترى النمط؟

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

أخيرًا، أصبح لديك shaderLocation. ويكون هذا الرقم عبارة عن رقم عشوائي يتراوح بين 0 و15، ويجب أن يكون فريدًا لكل سمة تحدّدها. وهي تربط هذه السمة بإدخال معين في أداة تظليل الرأس، والتي ستتعرف عليها في القسم التالي.

لاحظ أنه على الرغم من تحديد هذه القيم الآن، فأنت لا تمررها إلى WebGPU API في أي مكان حتى الآن. هذا قادم، ولكن من الأسهل التفكير في هذه القيم عند النقطة التي تحدد فيها رؤوسك، لذا عليك إعدادها الآن لاستخدامها لاحقًا.

البدء بأدوات التظليل

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

أدوات التظليل هي برامج صغيرة تكتبها ويتم تنفيذها على وحدة معالجة الرسومات. تعمل كل أداة تظليل على مرحلة مختلفة من البيانات: معالجة Vertex أو معالجة Fragment أو عملية Compute العامة. ونظرًا لأنّ هذه الأجهزة تستخدم وحدة معالجة الرسومات، فإنّها تتضمّن بنية أكثر صرامة من بنية رموز 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 إلى الدالة كوسيطة، وتكمن مهمة دالة تظليل الرأسي في عرض موضع مقابل في مساحة المقطع.

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

في WGSL، يمكن تسمية دالة تظليل رأسك كما تريد، ولكن يجب أن تكون هناك سمة @vertex أمامها للإشارة إلى مرحلة التظليل التي تمثلها. تشير WGSL إلى الدوال باستخدام الكلمة الرئيسية fn، وتستخدم الأقواس لتعريف أي وسيطات، وتستخدم الأقواس المعقوفة لتحديد النطاق.

  1. أنشئ دالة @vertex فارغة، مثل هذه:

index.html (رمز createShaderModule)

@vertex
fn vertexMain() {

}

هذا غير صحيح، إذ يجب أن تعرض أداة تظليل الرأس على الأقل الموضع النهائي للرأس الذي تتم معالجته في مساحة المقطع. دائمًا ما يُعرَف ذلك على أنه خط متجه رباعي الأبعاد. من الشائع استخدام المتجهات في أدوات التظليل، حيث يتم التعامل معها على أنها أساسات من الدرجة الأولى في اللغة، مع أنواعها الخاصة مثل vec4f لمتجه رباعي الأبعاد. وهناك أنواع متشابهة للمتجهات الثنائية الأبعاد (vec2f) والمتجهات الثلاثية الأبعاد (vec3f).

  1. للإشارة إلى أنّ القيمة التي يتم عرضها هي الموضع المطلوب، ضَع علامة عليها باستخدام السمة @builtin(position). يستخدم الرمز -> للإشارة إلى أن هذا ما تقوم بإرجاعه.

index.html (رمز CreateShaderModule)

@vertex
fn vertexMain() -> @builtin(position) vec4f {

}

بالطبع، إذا كان للدالة نوع إرجاع، فستحتاج إلى عرض قيمة في نص الدالة. ويمكنك إنشاء vec4f جديدة لعرضها باستخدام البنية vec4f(x, y, z, w). كل قيم x وy وz هي أرقام نقاط عائمة تشير في القيمة المعروضة إلى مكان الرأس في مساحة المقطع.

  1. يجب عرض قيمة ثابتة تساوي (0, 0, 0, 1)، وسيكون لديك من الناحية الفنية مظلل رأس صالح، على الرغم من أنّه لا يعرض أي شيء مطلقًا لأنّ وحدة معالجة الرسومات تدرك أنّ المثلثات التي تنتجها ما هي إلا نقطة واحدة، ثم تتجاهلها.

index.html (رمز CreateShaderModule)

@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 يبدو طبيعيًا.

  1. قم بتغيير وظيفة أداة التظليل إلى التعليمة البرمجية التالية:

index.html (رمز CreateShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1);
}

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

  1. عرض الموضع الصحيح من خلال الإشارة بشكل صريح إلى مكوّنات الموضع المراد استخدامها:

index.html (رمز CreateShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos.x, pos.y, 0, 1);
}

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

  1. أعِد كتابة العبارة return باستخدام الرمز التالي:

index.html (رمز CreateShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos, 0, 1);
}

وهذا هو أداة تظليل رأسك الأولية! الأمر بسيط للغاية، مجرد وضع المنصب دون تغيير فعليًا، لكن الشروع في البدء أمر جيد.

تحديد أداة تظليل الأجزاء

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

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

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

يُشار إلى دالة تظليل أجزاء WGSL بالسمة @fragment وتعرض أيضًا vec4f. في هذه الحالة، على الرغم من ذلك، يمثل المتجه لونًا وليس موضعًا. يجب منح القيمة المعروضة السمة @location للإشارة إلى colorAttachment الذي تتم كتابة اللون المعروض إليه من خلال استدعاء beginRenderPass. وبما أنّه كان لديك مرفق واحد فقط، فإن الموقع الجغرافي هو 0.

  1. أنشئ دالة @fragment فارغة، مثل هذه:

index.html (رمز CreateShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {

}

المكوّنات الأربعة للمتجه المعروض هي قيم اللون الأحمر والأخضر والأزرق وألفا، والتي يتم تفسيرها بالطريقة نفسها تمامًا مثل clearValue التي ضبطتها في beginRenderPass سابقًا. إذن vec4f(1, 0, 0, 1) باللون الأحمر الفاتح، والذي يبدو لونًا لائقًا لمربعك. لك مطلق الحرية في ضبطه على أي لون تريده، مع ذلك!

  1. قم بتعيين متجه اللون الذي تم إرجاعه، على النحو التالي:

index.html (رمز CreateShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}

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

للتلخيص، بعد إضافة رمز أداة التظليل المفصّل أعلاه، ستبدو المكالمة 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. ويتضمّن ذلك أيضًا وحدة أداة التظليل وenterPoint، مثل مرحلة الرأس. البت الأخير هو تحديد targets الذي يُستخدم فيه هذا المسار. هذه مصفوفة من القواميس التي تقدم تفاصيل، مثل زخرفة format، لمرفقات الألوان التي ينتج عنها المسار. يجب أن تتطابق هذه التفاصيل مع الزخارف الواردة في colorAttachments لأي تمريرات عرض يُستخدَم فيها هذا المسار. تستخدم بطاقة العرض زخارف من سياق لوحة الرسم، وتستخدم القيمة التي حفظتها في canvasFormat لتنسيقها، لذا يتم ضبط التنسيق نفسه هنا.

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

رسم المربّع

وبهذا، لديك الآن كل ما تحتاجه لرسم مربعك!

  1. لرسم المربّع، يمكنك الرجوع سريعًا إلى زوج المكالمتَين "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 عمودًا عائمًا / 2 إحداثيات لكل رأس == 6 رؤوس) يعني أنّه إذا قررت في أي وقت استبدال المربّع بدائرة، على سبيل المثال، لن تحتاج إلى تعديل العدد باليد.

  1. قم بتحديث شاشتك (وأخيرًا) شاهد نتائج كل عملك الجاد: مربع واحد كبير ملون.

يتم عرض مربّع أحمر واحد باستخدام WebGPU.

5- رسم شبكة

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

في هذا القسم، ستتعرف على:

  • كيفية تمرير المتغيرات (التي تُعرف باسم الزي الرسمي) إلى أداة التظليل من JavaScript.
  • كيفية استخدام الزي الرسمي لتغيير سلوك العرض
  • كيفية استخدام التثبيت لرسم العديد من المتغيرات المختلفة بنفس الشكل الهندسي.

تحديد الشبكة

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

  • حدد حجم الشبكة عن طريق إضافة ثابت في أعلى رمز JavaScript.

index.html

const GRID_SIZE = 4;

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

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

إنشاء مورد احتياطي منتظم

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

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

  • قم بإنشاء مورد احتياطي موحد بإضافة التعليمة البرمجية التالية:

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 باستخدامها عند الرسم. لحسن الحظ أن ذلك بسيط جدًا.

  1. الانتقال مجددًا إلى بطاقة العرض وإضافة هذا السطر الجديد قبل إجراء draw():

index.html

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);

pass.setBindGroup(0, bindGroup); // New line!

pass.draw(vertices.length / 2);

تتوافق 0 التي تم تمريرها كوسيطة أولى مع @group(0) في رمز أداة التظليل. يعني ذلك أن كل @binding جزء من @group(0) يستخدم الموارد المتوفّرة في مجموعة الربط هذه.

والآن يتم كشف المخزن المؤقت المنتظم لأداة التظليل الخاصة بك!

  1. حدّث صفحتك، وبعد ذلك سيظهر لك شيء على النحو التالي:

مربع أحمر صغير في وسط خلفية زرقاء داكنة

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

معالجة الأشكال الهندسية في أداة التظليل

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

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

صورة توضيحية للشبكة المفاهيمية التي سيتم تقسيم مساحة إدارة الجهاز التي تمت تسويتها عند إنشاء رسم بياني لكل خلية مع الأشكال الهندسية المربّعة المعروضة حاليًا في وسطها

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

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

تتمثل إحدى الطرق التي يمكنك من خلالها إصلاح هذا في تحديث المخزن المؤقت للرأس للمربع. عن طريق تغيير رؤوس الزوايا بحيث تكون الزاوية السفلية اليمنى عند (0.1، 0.1) على سبيل المثال، بدلاً من (-0.8، -0.8)، يمكنك تحريك هذا المربع لكي تتماشى مع حدود الخلية بشكل أكثر سلاسة. ولكن، بما أن لديك تحكمًا كاملاً في كيفية معالجة الرؤوس في أداة التظليل، يسهُل عليك تحريكها إلى مكانها باستخدام رمز أداة التظليل.

  1. عدّل وحدة تظليل الرأس باستخدام الرمز التالي:

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

يؤدي هذا الإجراء إلى نقل كلّ رأس إلى الأعلى وإلى اليمين بمقدار واحد (وهو ما يشكّل نصف مساحة المقطع) قبل قسمته على حجم الشبكة. والنتيجة هي مربع تمت محاذاته بشكل جيد بعيدًا عن المصدر.

تصور للوحة الرسم مقسم من الناحية النظرية إلى شبكة 4×4 مع مربع أحمر في الخلية (2، 2)

بعد ذلك، نظرًا لأن نظام الإحداثيات في لوحة الرسم لديك تضع (0، 0) في المنتصف و (-1، -1) في أسفل اليسار، وتريد (0، 0) أن تكون في أسفل اليسار، تحتاج إلى ترجمة موضع الهندسة على (-1، -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)!

تصور للوحة الرسم مقسم من الناحية النظرية إلى شبكة 4×4 مع مربع أحمر في الخلية (0، 0)

ماذا لو كنت تريد وضعه في خلية مختلفة؟ ويمكنك تحقيق ذلك من خلال تعريف المتّجه cell في أداة التظليل وتعبئة النص بقيمة ثابتة، مثل let cell = vec2f(1, 1).

إذا أضفت ذلك إلى gridPos، سيتم التراجع عن - 1 في الخوارزمية، وبالتالي هذا ليس ما تريد. بدلاً من ذلك، تريد نقل المربع فقط بواسطة وحدة شبكة واحدة (ربع لوحة الرسم) لكل خلية. يبدو أنك بحاجة إلى إجراء عملية قسمة أخرى على grid.

  1. قم بتغيير موضع الشبكة، كما يلي:

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

في حال إعادة التحميل الآن، سيظهر لك ما يلي:

تصور للوحة الرسم مقسم من الناحية النظرية إلى شبكة 4×4 مع مربع أحمر متمركز بين الخلية (0، 0)، الخلية (0، 1)، الخلية (1، 0)، والخلية (1، 1)

امم. ليس ما أردته تمامًا.

وسبب ذلك هو أنّه بما أنّ إحداثيات اللوحة من -1 إلى 1+، فهي تشكّل وحدتين فعليًا. هذا يعني إذا كنت تريد تحريك رأس ربع اللوحة وربع اللوحة، فيجب عليك تحريكه بمقدار 0.5 وحدة. هذا خطأ يسهل ارتكابه عند التفكير باستخدام إحداثيات وحدة معالجة الرسومات! لحسن الحظ، كان إصلاح المشكلة في هذه الحالة بالسهولة نفسها.

  1. اضرب الإزاحة في 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);
}

وهذا يمنحك ما تريد بالضبط.

تصور للوحة الرسم مقسم من الناحية النظرية إلى شبكة 4×4 مع مربع أحمر في الخلية (1، 1)

تظهر لقطة الشاشة على النحو التالي:

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

بالإضافة إلى ذلك، يمكنك الآن ضبط cell على أي قيمة داخل حدود الشبكة، ثم إعادة تحميل الصفحة لعرض عرض المربّع في الموقع المطلوب.

رسم حالات

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

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

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

  1. لإخبار وحدة معالجة الرسومات أنك تريد مثيلات كافية من المربع لملء الشبكة، أضف وسيطة واحدة إلى طلب الرسم الحالي:

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 لمطابقة مثال الرمز.) ثم استخدمها كجزء من منطق التظليل!

  1. استخدم 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 بحيث يتم ربط كل فهرس بخلية فريدة داخل شبكتك، كما يلي:

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

الرياضيات لذلك واضحة بشكل معقول. بالنسبة إلى قيمة X لكل خلية، تريد modulo للسمة instance_index وعرض الشبكة، والذي يمكنك تنفيذه باستخدام WGSL باستخدام عامل التشغيل %. وبالنسبة إلى قيمة Y لكل خلية، تريد قسمة instance_index على عرض الشبكة، مع تجاهل أي باقي كسور. يمكنك إجراء ذلك باستخدام الدالة floor() في WGSL.

  1. قم بتغيير العمليات الحسابية، على النحو التالي:

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

بعد إجراء هذا التحديث على التعليمة البرمجية، لديك شبكة المربعات التي طال انتظارها أخيرًا!

أربعة صفوف من أربعة أعمدة من المربعات الحمراء على خلفية زرقاء داكنة.

  1. والآن بعد أن نجحت، عد وقم بزيادة حجم الشبكة!

index.html

const GRID_SIZE = 32;

32 صفًا من 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 قبل الرجوع.

  1. غيِّر القيمة التي تعرضها أداة تظليل الرأس، كما يلي:

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;
}
  1. في الدالة @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);
}
  1. بدلاً من ذلك، يمكنك استخدام struct بدلاً من ذلك:

index.html (طلب createShaderModule)

struct FragInput {
  @location(0) cell: vec2f,
};

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  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!

  1. قم بتغيير برنامج تظليل الأجزاء، كما يلي:

index.html (طلب createShaderModule)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell/grid, 0, 1);
}

حدّث الصفحة، وسترى أن الرمز الجديد يفعل تدرج الألوان الرائع عبر الشبكة بالكامل.

شبكة من المربعات التي تنتقل من الأسود إلى الأحمر، إلى الأخضر، إلى الأصفر في زوايا مختلفة.

في حين أن هذا بالتأكيد يعد تحسينًا، إلا أن هناك الآن زاوية مظلمة سيئة في أسفل اليسار، حيث تصبح الشبكة سوداء. عندما تبدأ بمحاكاة لعبة Game of Life، سيحجب الجزء الذي يصعب رؤيته من الشبكة ما يجري. سيكون من اللطيف زيادة سطوع ذلك.

لحسن الحظ، لديك قناة ألوان كاملة غير مستخدمة - زرقاء - يمكنك استخدامها. التأثير الذي تريده بشكل مثالي هو أن يكون اللون الأزرق أكثر سطوعًا حيث تكون الألوان الأخرى أغمق، ثم تتلاشى مع زيادة كثافة الألوان الأخرى. أسهل طريقة لإجراء ذلك هي أن تكون القناة تبدأ عند الرقم 1 وتطرح إحدى قيم الخلية. يمكن أن تكون إما c.x أو c.y. يمكنك تجربة الاثنين معًا، ثم اختيار النوع الذي تفضّله.

  1. أضف ألوانًا أكثر إشراقًا إلى تظليل الأجزاء، كما يلي:

استدعاء createShaderModule

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  let c = input.cell / grid;
  return vec4f(c, 1-c.x, 1);
}

تبدو النتيجة رائعة جدًا.

شبكة من المربعات التي تنتقل من الأحمر إلى الأخضر إلى الأزرق إلى الأصفر في زوايا مختلفة.

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

7. إدارة حالة الخلية

بعد ذلك، عليك التحكّم في الخلايا التي يتم عرضها على الشبكة بناءً على الحالة المخزّنة في وحدة معالجة الرسومات. وهذا مهم للمحاكاة النهائية!

كل ما تحتاجه هو إشارة تشغيل لكل خلية، وبالتالي فإن أي خيارات تتيح لك تخزين صفيف كبير من أي نوع من القيم تقريبًا تعمل. قد تعتقد أن هذه حالة استخدام أخرى للموردين الاحتياطيين الموحدة! وعلى الرغم من إمكانية نجاح ذلك، إلا أن الأمر أصعب لأنّ المخزن المؤقت الموحَّد محدود الحجم، ولا يمكنها دعم الصفائف ذات الحجم الديناميكي (عليك تحديد حجم الصفيف في أداة التظليل)، ولا يمكن الكتابة إليها باستخدام أدوات التظليل. هذا العنصر الأخير هو الأكثر إشكالية، لأنّك تحتاج إلى تنفيذ محاكاة Game of Life على وحدة معالجة الرسومات باستخدام أداة تظليل الحوسبة.

لحسن الحظ، هناك خيار احتياطي آخر يتجنب كل هذه القيود.

إنشاء مخزن مؤقت

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

  1. لإنشاء مخزن مؤقت للتخزين لحالة الخلية، استخدم ما - ربما بدأ - الآن - في أن يكون مقتطفًا مألوفًا لرمز إنشاء المخزن المؤقت:

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(). لأنك تريد رؤية تأثير المخزن المؤقت الخاص بك على الشبكة، ابدأ بملئه بشيء يمكن التنبؤ به.

  1. تفعيل كل خلية ثالثة بالتعليمة البرمجية التالية:

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

قراءة مساحة التخزين المؤقتة في أداة التظليل

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

  1. عدِّل أداة التظليل باستخدام الرمز التالي:

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 إلى تصغير الشكل الهندسي إلى نقطة واحدة، والتي تتجاهلها وحدة معالجة الرسومات بعد ذلك.

  1. عدِّل رمز أداة التظليل لتعديل الموضع حسب الحالة النشطة للخلية. يجب تحويل قيمة الحالة إلى 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); 
  1. استخدم هذا النمط في التعليمات البرمجية الخاصة بك عن طريق تحديث تخصيص المخزن المؤقت للتخزين لإنشاء موردين احتياطيين متطابقين:

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,
  })
];
  1. للمساعدة في تصور الفرق بين المصدرين الاحتياطيين، قم بملئهما ببيانات مختلفة:

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);
  1. لعرض المخازن المؤقتة المختلفة لسعة التخزين أثناء العرض، عدِّل مجموعات الربط لتتضمّن صيغتين مختلفتين أيضًا:

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 مرة كل ثانية).

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

  1. أولاً، اختر معدلاً ليتم تحديث المحاكاة عند (200 ملّي ثانية جيدة، ولكن يمكنك العمل ببطء أو أسرع إذا أردت)، ثم تتبع عدد خطوات المحاكاة التي تم إكمالها.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. بعد ذلك، انقل كل الرموز البرمجية التي تستخدمها حاليًا للعرض إلى دالة جديدة. يمكنك جدولة هذه الدالة لتكرارها في الفاصل الزمني المطلوب باستخدام 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);

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

خطوط قطرية لمربّعات ملوّنة تنتقل من أسفل اليسار إلى أعلى اليمين على خلفية زرقاء داكنة خطوط عمودية من مربّعات ملوّنة على خلفية زرقاء داكنة

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

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

8. استمتِع بالمحاكاة

سنتحدّث الآن عن آخر جزء رئيسي من اللغز، وهو إجراء محاكاة Game of Life باستخدام أداة تظليل الحوسبة.

أخيرًا، استخدِم أدوات تظليل الحوسبة.

لقد تعرّفت بالفعل على أدوات التظليل الحوسبية خلال هذا الدرس التطبيقي حول الترميز، ولكن ما هي بالضبط؟

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

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

  1. أنشئ أداة تظليل حوسبة بالرمز التالي:

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() {

    }`
});

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

نظرًا لطبيعة بنية أجهزة وحدة معالجة الرسومات، يتم تقسيم هذه الشبكة إلى مجموعات عمل. مجموعة العمل لها حجم X وY وZ، ورغم أن الأحجام يمكن أن تكون 1 لكل منها، فغالبًا ما تكون هناك فوائد أداء لجعل مجموعات العمل أكبر قليلاً. بالنسبة إلى أداة التظليل، اختَر حجم مجموعة عمل عشوائيًا إلى حد ما مكوّن من 8 ضرب 8. ويكون ذلك مفيدًا لتتبُّعه في رمز JavaScript.

  1. حدد ثابتًا لحجم مجموعة العمل الخاصة بك، مثل هذا:

index.html

const WORKGROUP_SIZE = 8;

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

  1. أضف حجم مجموعة العمل إلى دالة التظليل، كما يلي:

index.html (طلب Compute createShaderModule)

@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {

}

هذا يخبر أداة التظليل بأن العمل المنجز باستخدام هذه الدالة تم في مجموعات (8 × 8 × 1). (يتم تعيين أي محور تتركه افتراضيًا على 1، على الرغم من أنه يجب عليك تحديد المحور س على الأقل).

كما هو الحال مع مراحل أداة التظليل الأخرى، هناك مجموعة متنوعة من قيم @builtin التي يمكنك قبولها كإدخال في وظيفة أداة تظليل الحوسبة لإخبارك بالاستدعاء الذي تستخدمه وتحديد العمل الذي تريد تنفيذه.

  1. أضِف قيمة @builtin، كما يلي:

index.html (طلب Compute createShaderModule)

@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)، مما يعني أنه يمكنك التعامل معه كفهرس الخلية الذي ستعمل عليه!

يمكن لأدوات تظليل الحوسبة أيضًا استخدام الزي الرسمي، الذي تستخدمه تمامًا كما في أدوات تظليل الأجزاء والرأس.

  1. استخدم توحيدًا مع أداة تظليل الحوسبة لإخبارك بحجم الشبكة، على النحو التالي:

index.html (طلب Compute createShaderModule)

@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) {

}

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

  1. اعرض مدخلات الخلية وحالة الإخراج كوحدات تخزين مؤقتة، على النحو التالي:

index.html (طلب Compute createShaderModule)

@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)).)

  1. اكتب دالة للانتقال في الاتجاه الآخر. تأخذ قيمة Y للخلية، وتضربها في عرض الشبكة، ثم تضيف قيمة X للخلية.

index.html (طلب Compute createShaderModule)

@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) {
  
}

وأخيرًا، لكي تتأكد من نجاحها، نفِّذ خوارزمية بسيطة حقًا: إذا كانت الخلية قيد التشغيل في الوقت الحالي، يتم إيقاف تشغيلها، والعكس صحيح. اللعبة ليست Game of Life بعد، ولكن يكفي توضيح أنّ أداة تظليل الحوسبة تعمل.

  1. أضف الخوارزمية البسيطة، كما يلي:

index.html (طلب Compute createShaderModule)

@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 نفسها للمخزن المؤقت الموحَّد وأول مخزن تخزين مؤقت، يمكنك مشاركة تلك القيم بين مسارات التعلّم، ويتجاهل مسار العرض المخزن المؤقت الثاني للتخزين، الذي لا يستخدمه. تحتاج إلى إنشاء تنسيق يصف جميع الموارد الموجودة في مجموعة الربط، وليس فقط الموارد التي يستخدمها مسار معين.

  1. لإنشاء هذا التنسيق، يمكنك طلب 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، يمكنك تمريرها عند إنشاء مجموعات الربط بدلاً من الاستعلام عن مجموعة الربط من مسار التعلّم. يعني ذلك أنك بحاجة إلى إضافة إدخال جديد للمخزن المؤقت للتخزين إلى كل مجموعة ربط من أجل مطابقة التنسيق الذي حددته للتو.

  1. عدِّل عملية إنشاء مجموعة الربط، كما يلي:

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] }
    }],
  }),
];

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

  1. إنشاء GPUPipelineLayout

index.html

const pipelineLayout = device.createPipelineLayout({
  label: "Cell Pipeline Layout",
  bindGroupLayouts: [ bindGroupLayout ],
});

تخطيط المسار هو قائمة تخطيطات مجموعة الربط (في هذه الحالة، لديك واحدة) التي يستخدمها مسار واحد أو أكثر. يجب أن يتوافق ترتيب تصميمات مجموعة الربط في المصفوفة مع سمات @group في أدوات التظليل. (يعني ذلك أنّ bindGroupLayout مرتبط بالنطاق @group(0)).

  1. بعد الحصول على تنسيق المسار، عدِّل مسار العرض لاستخدامه بدلاً من "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"، كما هو الحال في مسار العرض المعدَّل، ما يضمن أن يستخدم مسار العرض ومسار الحوسبة مجموعات الربط نفسها.

احتساب البطاقات

وهذا يقودك إلى نقطة الاستفادة الفعلية من مسار الحوسبة! بما أنّك تُجري العرض من خلال بطاقة عرض، يمكنك على الأرجح تخمين أنّك بحاجة إلى إجراء عملية حوسبة في بطاقة حوسبة. يمكن تنفيذ مهام الحوسبة والعرض باستخدام برنامج ترميز تنفيذ الأوامر نفسه، لذا عليك تشغيل وظيفة updateGrid عشوائيًا قليلاً.

  1. انقل عملية إنشاء برنامج الترميز إلى أعلى الدالة ثم ابدأ عملية الحوسبة باستخدامها (قبل 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 بين التمريرات، بحيث يصبح المخزن المؤقت للمخرجات في مسار الحوسبة هو المورد الاحتياطي للمدخلات لمسار العرض.

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

index.html

const computePass = encoder.beginComputePass();

// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

computePass.end();
  1. وأخيرًا، بدلاً من الرسم كما هو الحال في تصريح العرض، يمكنك نقل العمل إلى أداة تظليل الحوسبة من خلال تحديد عدد مجموعات العمل التي تريد تنفيذها على كل محور.

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 في أداة التظليل.

إذا أردت تنفيذ أداة التظليل 32x32 مرة لتغطية شبكتك بأكملها، وكان حجم مجموعة العمل 8x8، فستحتاج إلى إرسال مجموعات العمل 4x4 (4 * 8 = 32). ولهذا السبب تقسم حجم الشبكة على حجم مجموعة العمل وتمرر هذه القيمة إلى dispatchWorkgroups().

يمكنك الآن إعادة تحميل الصفحة مرة أخرى، ومن المفترض أن تلاحظ انعكاس الشبكة نفسها مع كل تعديل.

خطوط قطرية لمربّعات ملوّنة تنتقل من أسفل اليسار إلى أعلى اليمين على خلفية زرقاء داكنة خطوط قطرية لمربّعات ملوّنة (مربّعان) يتم عرضهما من أسفل اليسار إلى أعلى اليمين على خلفية زرقاء داكنة قلب الصورة السابقة

تطبيق الخوارزمية للعبة Game of Life

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

  1. لبدء كل خلية في حالة عشوائية، عليك تعديل إعداد 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);

أصبح بإمكانك الآن تنفيذ فكرة محاكاة Game of Life. بعد كل ما تطلبه للوصول إلى هنا، قد لا يكون رمز أداة التظليل بسيطًا للغاية.

ينبغي أولًا أن تعرف عدد العناصر المجاورة النشطة لأي خليةٍ ما. ولا يهمّك أيها نشط، بل العدد فقط.

  1. لتسهيل الحصول على بيانات الخلية المجاورة، أضف الدالة cellActive التي تعرض القيمة cellStateIn للإحداثي المحدد.

index.html (طلب Compute createShaderModule)

fn cellActive(x: u32, y: u32) -> u32 {
  return cellStateIn[cellIndex(vec2(x, y))];
}

تعرض الدالة cellActive قيمة واحدة إذا كانت الخلية نشطة، وبالتالي فإن إضافة القيمة الناتجة لاستدعاء cellActive لجميع الخلايا الثمانية المحيطة تعطيك عدد الخلايا المجاورة النشطة.

  1. أوجِد عدد الجيران النشطين، كما يلي:

index.html (طلب Compute createShaderModule)

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() في الوقت الحالي، إمّا أن يتجاوز الصف إلى الصف التالي أو السابق، أو أن ينفد من حافة المخزن المؤقت.

بالنسبة لـ Game of Life، هناك طريقة شائعة وسهلة لحل هذه المشكلة وهي أن تكون الخلايا على حافة الشبكة معالجة الخلايا على الحافة المقابلة للشبكة كجيران، مما يخلق نوعًا من تأثير الالتفاف.

  1. دعم التفاف الشبكة مع إجراء تغيير بسيط على دالة cellIndex().

index.html (طلب Compute createShaderModule)

fn cellIndex(cell: vec2u) -> u32 {
  return (cell.y % u32(grid.y)) * u32(grid.x) +
         (cell.x % u32(grid.x));
}

باستخدام عامل التشغيل % لجعل الخلية X وY تتجاوز حجم الشبكة، يمكنك ضمان عدم وصولك أبدًا خارج حدود المخزن المؤقت للتخزين. وبذلك، يمكنك الاطمئنان إلى أنّ عدد activeNeighbors يمكن توقُّعه.

ثم تطبق إحدى القواعد الأربع:

  • تصبح أي خلية لها أقل من جارتين غير نشطة.
  • وتظل أي خلية نشطة بها جاران أو ثلاثة جيران نشطة.
  • تصبح أي خلية غير نشطة لها ثلاث جيران بالضبط نشطة.
  • تصبح أي خلية بها أكثر من ثلاث جيران غير نشطة.

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

  1. نفِّذ مبدأ Game of Life على النحو التالي:

index.html (طلب Compute createShaderModule)

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

وهذا كل ما في الأمر! لقد أنهيت عملك! أعِد تحميل صفحتك وشاهد نمو جهازك الجوّال الذي تم إنشاؤه حديثًا.

لقطة شاشة تُظهر مثالًا على حالة من محاكاة Game of Life، وتعرض خلايا ملوّنة على خلفية زرقاء داكنة

9. تهانينا

لقد أنشأت نسخة من لعبة محاكاة Game of Life الكلاسيكية من Conway التي تعمل بالكامل على وحدة معالجة الرسومات باستخدام WebGPU API.

الخطوات التالية

قراءة إضافية

المستندات المرجعية