أول تطبيق WebGPU

1. مقدمة

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

ما هو WebGPU؟

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

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

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

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

العرض

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

المعالجة

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

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

التطبيق الذي ستصممه

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

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

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

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

ما ستتعرّف عليه

  • كيفية إعداد 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 ولكنّ وحدة معالجة الرسومات (GPU) لا تتضمّن جميع الميزات اللازمة لاستخدام WebGPU.

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

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

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

index.html

const device = await adapter.requestDevice();

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

ضبط مساحة العرض

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

  • لإجراء ذلك، عليك أولاً طلب GPUCanvasContext من اللوحة من خلال الاتصال برقم canvas.getContext("webgpu"). (هذا هو الطلب نفسه الذي ستستخدمه لإعداد سياقات Canvas 2D أو WebGL باستخدام نوعَي السياق 2d وwebgl على التوالي). يجب بعد ذلك ربط context الذي يتم إرجاعه بالجهاز باستخدام طريقة configure()، على النحو التالي:

index.html

const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device: device,
  format: canvasFormat,
});

هناك بضعة خيارات يمكن تمريرها هنا، ولكن أهمها device الذي ستستخدم السياق معه وformat، وهو تنسيق النسيج الذي يجب أن يستخدمه السياق.

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

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

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

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

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

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

index.html

const encoder = device.createCommandEncoder();

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

بطاقات العرض هي عند حدوث كل عمليات الرسم في 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 وZ (للمحتوى الثلاثي الأبعاد) التي تحدّد نقطة على نظام إحداثيات كارتيزية تحدّده WebGPU أو واجهات برمجة التطبيقات المشابهة. من الأسهل التفكير في بنية نظام الإحداثيات من حيث علاقته بمساحة الرسم على صفحتك. بغض النظر عن عرض اللوحة أو ارتفاعها، تكون الحافة اليسرى دائمًا عند -1 على محور X، وتكون الحافة اليمنى دائمًا عند +1 على محور X. وبالمثل، تكون الحافة السفلية دائمًا -1 على المحور ص، والحافة العلوية هي +1 على المحور ص. وهذا يعني أنّ (0, 0) هو دائمًا مركز اللوحة، و(-1, -1) هو دائمًا الزاوية السفلية اليسرى، و (1, 1) هو دائمًا الزاوية العلوية اليمنى. وتُعرف هذه المساحة باسم مساحة المقطع.

رسم بياني بسيط يوضّح مساحة &quot;إحداثيات الجهاز المحسوبة&quot;

نادرًا ما يتمّ تحديد الرؤوس في نظام الإحداثيات هذا في البداية، لذا تعتمد وحدات معالجة الرسومات على برامج صغيرة تُسمّى برامج تظليل الرؤوس لإجراء أيّ عمليات حسابية ضرورية لتحويل الرؤوس إلى مساحة القصاصة، بالإضافة إلى أيّ عمليات حسابية أخرى مطلوبة لرسم الرؤوس. على سبيل المثال، قد يطبّق برنامج التظليل بعض الصور المتحركة أو يحسب الاتجاه من رأس المضلع إلى مصدر الإضاءة. تمت كتابة أدوات التظليل هذه بنفسك، كمطور 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، مع دمج علامات متعددة باستخدام عامل التشغيل | ( أو الثنائي). في هذه الحالة، لنفترض أنّك تريد استخدام المخزن المؤقت لبيانات رأس الصفحة (GPUBufferUsage.VERTEX) وأنّك تريد أيضًا أن تكون قادرًا على نسخ البيانات إليه (GPUBufferUsage.COPY_DST).

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

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

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

index.html

device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);

تحديد تنسيق الرأس

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

index.html

const vertexBufferLayout = {
  arrayStride: 8,
  attributes: [{
    format: "float32x2",
    offset: 0,
    shaderLocation: 0, // Position, see vertex shader
  }],
};

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

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

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

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

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

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

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

البدء باستخدام مواد التشويش

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

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

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

يتم تمرير shaders نفسها إلى 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. غيِّر دالة Shader إلى الرمز البرمجي التالي:

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

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

تحديد برنامج تشويش الأجزاء

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

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

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

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

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

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

  • كيفية تمرير المتغيّرات (المعروفة باسم متغيّرات موحدة) إلى برنامج التظليل من JavaScript
  • كيفية استخدام التنسيقات لتغيير سلوك العرض
  • كيفية استخدام وضع النماذج لرسم العديد من الصيغ المختلفة للشكل الهندسي نفسه

تحديد الشبكة

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

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

index.html

const GRID_SIZE = 4;

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

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

إنشاء مخزن موحّد

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

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

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

index.html

// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
  label: "Grid Uniforms",
  size: uniformArray.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);

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

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

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

التلاعب بالأشكال الهندسية في برنامج تشويش الصورة

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

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

رسم توضيحي للشبكة المفهومية التي ستتم تقسيم مساحة &quot;إحداثيات الجهاز المحسوبة&quot; عند عرض كل خلية باستخدام الشكل الهندسي المربّع المعروض حاليًا في وسطها

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

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

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

رسم النماذج

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

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

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

استخدام البنى في برامج التظليل

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

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

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، سيحجب الجزء الذي يصعب رؤيته من الشبكة ما يجري. سيكون من الجيد تحسين ذلك.

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

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

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

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

إنشاء مساحة تخزين مؤقتة

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

  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() للقيمة المقابلة في برنامج تشويش الصورة.

بعد إجراء ذلك، من المفترض أن تتمكّن من إعادة تحميل الصفحة ورؤية النمط يظهر في الشبكة.

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

استخدام نمط "تبديل التخزين المؤقت"

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

ما أهمية ذلك؟ انظر إلى مثال مبسط: تخيل أنك تكتب محاكاة بسيطة للغاية تنقل فيها أي كتل نشطة إلى خلية واحدة في كل خطوة. لتسهيل فهم الأمور، يمكنك تحديد البيانات والمحاكاة في 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);

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

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

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

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

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

الآن، نأتي إلى الخطوة الرئيسية الأخيرة: تنفيذ محاكاة لعبة "البقاء للأقوى" في برنامج تظليل حسابي.

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

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

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

يجب إنشاء برامج تظليل الحوسبة في وحدة تظليل، تمامًا مثل برامج تظليل رؤوس العناصر والشرائح، لذا أضِف ذلك إلى الرمز البرمجي للبدء. كما يمكنك توقّع، يجب وضع السمة @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 (طلب createShaderModule في Compute)

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

}

يُعلم هذا المخطِّط اللوني أنّ العمل الذي تمّ إجراؤه باستخدام هذه الدالة يتمّ في مجموعات (8 x 8 x 1). (أي محور لا تحدّده يكون تلقائيًا 1، ولكن عليك تحديد محور X على الأقل).

كما هو الحال مع مراحل تظليل أخرى، هناك مجموعة متنوعة من قيم @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 call)

@group(0) @binding(0) var<uniform> grid: vec2f;
    
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

يُرجى العِلم أنّه تمّ الإعلان عن وحدة تخزين المؤقتة الأولى باستخدام var<storage>، ما يجعلها للقراءة فقط، ولكن تمّ الإعلان عن وحدة تخزين المؤقتة الثانية باستخدام var<storage, read_write>. يتيح لك ذلك القراءة والكتابة في المخزن المؤقت، باستخدام هذا المخزن المؤقت كإخراج لظلال الحساب. (لا يتوفّر وضع تخزين للكتابة فقط في WebGPU).

بعد ذلك، ستحتاج إلى طريقة لربط فهرس الخلية بصفيف التخزين الخطي. هذا في الأساس هو عكس ما فعلته في أداة تظليل الرأس، حيث اتخذت instance_index الخطي وربطته بخلية شبكة ثنائية الأبعاد. (تذكير: كانت الخوارزمية التي استخدمتها هي vec2f(i % grid.x, floor(i / grid.x)).)

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

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

  1. أضِف الخوارزمية البسيطة، على النحو التالي:

index.html (Compute createShaderModule call)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines. Flip the cell state every step.
  if (cellStateIn[cellIndex(cell.xy)] == 1) {
    cellStateOut[cellIndex(cell.xy)] = 0;
  } else {
    cellStateOut[cellIndex(cell.xy)] = 1;
  }
}

لقد انتهينا من الحديث عن وحدات تظليل الحوسبة، إلى الآن. ولكن قبل أن تظهر لك النتائج، عليك إجراء بعض التغييرات الأخرى.

استخدام تنسيقات مجموعات الربط ومسار الإحالة الناجحة

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

في أيّ وقت تنشئ فيه مجموعة ربط، عليك تقديم GPUBindGroupLayout. في السابق، كان بإمكانك الحصول على هذا التنسيق من خلال استدعاء getBindGroupLayout() في مسار العرض، ما أدى بدوره إلى إنشاؤه تلقائيًا لأنّك قدّمت layout: "auto" عند إنشائه. تعمل هذه الطريقة بشكل جيد عندما تستخدم مسارًا واحدًا فقط، ولكن إذا كان لديك العديد من المسارات التي تريد مشاركة الموارد، فأنت بحاجة إلى إنشاء التخطيط بشكل صريح، ثم تقديمه إلى كل من مجموعة الربط والمسارات.

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

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

إنشاء مسار الإحالة الناجحة للمعالجة

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

  • أنشئ مسارًا متسلسلًا للمعالجة باستخدام الرمز البرمجي التالي:

index.html

// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
  label: "Simulation pipeline",
  layout: pipelineLayout,
  compute: {
    module: simulationShaderModule,
    entryPoint: "computeMain",
  }
});

لاحِظ أنّك تمرِّر pipelineLayout الجديدة بدلاً من "auto"، كما هو الحال في مسار العرض المعدَّل، ما يضمن أن يستخدم مسار العرض ومسار الحوسبة مجموعات الربط نفسها.

بطاقات Compute

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

  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 في برنامج التظليل.

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

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

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

تطبيق الخوارزمية للعبة 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 call)

fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines:
  // Determine how many active neighbors this cell has.
  let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                        cellActive(cell.x+1, cell.y) +
                        cellActive(cell.x+1, cell.y-1) +
                        cellActive(cell.x, cell.y-1) +
                        cellActive(cell.x-1, cell.y-1) +
                        cellActive(cell.x-1, cell.y) +
                        cellActive(cell.x-1, cell.y+1) +
                        cellActive(cell.x, cell.y+1);

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

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

  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 تتيح أيضًا عبارات switch، وهي مناسبة لهذا المنطق.

  1. نفِّذ منطق لعبة "البقاء على قيد الحياة"، على النحو التالي:

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

وهذا كل ما في الأمر! لقد أنهيت عملك! يُرجى إعادة تحميل الصفحة ومشاهدة آلة الخلية التي تم إنشاؤها حديثًا وهي تنمو.

لقطة شاشة لحالة مثالية من محاكاة لعبة الحياة، مع عرض خلايا ملونة على خلفية زرقاء داكنة

9. تهانينا!

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

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

مراجع إضافية

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