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