1. مقدمة
ما هي WebGPU؟
WebGPU هي واجهة برمجة تطبيقات جديدة وحديثة تتيح لك الاستفادة من إمكانات وحدة معالجة الرسومات في تطبيقات الويب.
Modern API
قبل WebGPU، كان هناك WebGL الذي يقدّم مجموعة فرعية من ميزات WebGPU. أتاح ذلك فئة جديدة من محتوى الويب الوافي، وأنشأ مطوّرو البرامج باستخدامه أشياء رائعة. ومع ذلك، كان يستند إلى واجهة برمجة التطبيقات OpenGL ES 2.0 التي تم إصدارها في عام 2007، والتي كانت تستند إلى واجهة برمجة التطبيقات OpenGL الأقدم. تطورت وحدات معالجة الرسومات بشكل كبير خلال هذه الفترة، وتطوّرت أيضًا واجهات برمجة التطبيقات الأصلية المستخدَمة للتفاعل معها، مثل Direct3D 12 وMetal وVulkan.
توفّر WebGPU ميزات واجهات برمجة التطبيقات الحديثة هذه في منصة الويب. تركّز هذه المنصة على تفعيل ميزات وحدة معالجة الرسومات على عدّة منصات، مع تقديم واجهة برمجة تطبيقات تبدو طبيعية على الويب وأقل تفصيلاً من بعض واجهات برمجة التطبيقات الأصلية التي تستند إليها.
العرض
غالبًا ما ترتبط وحدات معالجة الرسومات بعرض رسومات سريعة ومفصّلة، ولا يُستثنى من ذلك WebGPU. ويحتوي على الميزات المطلوبة للتوافق مع العديد من تقنيات العرض الأكثر شيوعًا حاليًا في كل من وحدات معالجة الرسومات على أجهزة الكمبيوتر المكتبي والأجهزة الجوّالة، كما يوفّر مسارًا لإضافة ميزات جديدة في المستقبل مع استمرار تطوّر إمكانات الأجهزة.
المعالجة
بالإضافة إلى العرض، تتيح وحدة معالجة الرسومات WebGPU إمكانات وحدة معالجة الرسومات لديك لتنفيذ مهام عامة وأعباء عمل متوازية بشكل كبير. ويمكن استخدام أدوات التظليل الحوسبية بشكل مستقل، أو بدون أي مكوّن عرض، أو باعتبارها جزءًا متكاملاً بإحكام من مسار العرض.
في ورشة رموز اليوم، ستتعرّف على كيفية الاستفادة من إمكانات المعالجة والعرض في WebGPU لإنشاء مشروع تمهيدي بسيط.
التطبيق الذي ستصممه
في هذا الدرس التطبيقي حول الترميز، يمكنك إنشاء Conway's Game of Life باستخدام WebGPU. سينفّذ تطبيقك ما يلي:
- استخدِم إمكانات التقديم في WebGPU لرسم رسومات ثنائية الأبعاد بسيطة.
- استخدِم إمكانات الحوسبة في WebGPU لتنفيذ المحاكاة.
"لعبة الحياة" هي ما يُعرف بالآلة الخلوية، حيث تتغيّر حالة شبكة الخلايا بمرور الوقت استنادًا إلى مجموعة من القواعد. تصبح خلايا Game of Life نشطة أو غير نشطة بناءً على عدد الخلايا المجاورة النشطة لها، ما يؤدي إلى أنماط مثيرة للاهتمام تتذبذب أثناء المشاهدة.
ما ستتعرّف عليه
- كيفية إعداد WebGPU وضبط لوحة
- كيفية رسم هندسة بسيطة ثنائية الأبعاد.
- كيفية استخدام أدوات تظليل رأس الأجزاء وأجزاء الرأس لتعديل ما يتم رسمه.
- كيفية استخدام أدوات التظليل الحوسبية لإجراء محاكاة بسيطة.
يركز هذا الدرس التطبيقي حول الترميز على تقديم المفاهيم الأساسية وراء WebGPU. ولا يُقصد منها أن تكون مراجعة شاملة لواجهة برمجة التطبيقات، كما أنها لا تغطي (أو تتطلب) المواضيع ذات الصلة بشكل متكرر مثل العمليات الحسابية للمصفوفة الثلاثية الأبعاد.
المتطلبات
- إصدار حديث من Chrome (113 أو إصدار أحدث) على ChromeOS أو macOS أو Windows WebGPU هي واجهة برمجة تطبيقات تعمل على عدة متصفِّحات وتعمل على الأنظمة الأساسية، ولكن لم يتم شحنها إلى كل المنصات حتى الآن.
- معرفة HTML وJavaScript وأدوات مطوّري البرامج في Chrome
ليس من الضروري أن تكون على دراية بواجهات برمجة التطبيقات الأخرى لرسومات الحاسوب، مثل WebGL أو Metal أو Vulkan أو Direct3D، ولكن إذا كانت لديك أيّ خبرة بها، من المرجّح أن تلاحظ الكثير من أوجه التشابه مع WebGPU التي قد تساعدك في بدء التعلّم.
2. الإعداد
الحصول على الرمز
لا يحتوي هذا الدليل التعليمي على أيّ عناصر تابعة، ويرشدك خلال كلّ خطوة مطلوبة لإنشاء تطبيق WebGPU، لذا لن تحتاج إلى أيّ رمز لبدء العمل. ومع ذلك، تتوفّر بعض الأمثلة العملية التي يمكن استخدامها كنقاط فحص على https://glitch.com/edit/#!/your-first-webgpu-app. يمكنك الاطّلاع عليها والإشارة إليها أثناء التنقّل إذا واجهتك مشكلة.
استخدام "وحدة تحكّم المطوّر"
WebGPU هي واجهة برمجة تطبيقات معقّدة إلى حدٍ ما تحتوي على الكثير من القواعد التي تفرض الاستخدام الصحيح. والأسوأ من ذلك، بسبب طريقة عمل واجهة برمجة التطبيقات، لا يمكنها عرض استثناءات JavaScript المعتادة للعديد من الأخطاء، ما يجعل من الصعب تحديد مصدر المشكلة بدقة.
ستواجه مشاكل عند التطوير باستخدام WebGPU، خاصةً إذا كنت مبتدئًا، وهذا أمر طبيعي. يدرك المطوّرون المسؤولون عن واجهة برمجة التطبيقات تحديات العمل على تطوير وحدة معالجة الرسومات، وقد عملوا جاهدين لضمان أنّه في أي وقت يتسبب رمز WebGPU في حدوث خطأ، ستتلقّى رسائل مفصّلة ومفيدة جدًا في وحدة تحكّم المطوّر لمساعدتك في تحديد المشكلة وحلّها.
من المفيد دائمًا إبقاء وحدة التحكّم مفتوحة أثناء العمل على أي تطبيق ويب، ولكن ينطبق ذلك بشكل خاص هنا.
3- بدء WebGPU
ابدأ بـ <canvas>
.
يمكن استخدام WebGPU بدون عرض أي شيء على الشاشة إذا كنت تريد استخدامه لإجراء عمليات حسابية فقط. ولكن إذا أردت عرض أي محتوى، كما سنفعل في ورشة رموز البرامج، ستحتاج إلى لوحة. هذا مكان جيد للبدء.
يمكنك إنشاء مستند HTML جديد يحتوي على عنصر <canvas>
واحد بالإضافة إلى علامة <script>
لطلب البحث عن عنصر لوحة الرسم. (أو استخدِم 00- Starter-page.html من glitch).
- أنشئ ملف
index.html
باستخدام الرمز التالي:
index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WebGPU Life</title>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script type="module">
const canvas = document.querySelector("canvas");
// Your WebGPU code will begin here!
</script>
</body>
</html>
طلب محوّل وجهاز
يمكنك الآن الاطّلاع على ميزات WebGPU. أولاً، يجب مراعاة أنّ واجهات برمجة التطبيقات، مثل WebGPU، قد تستغرق بعض الوقت للانتشار في منظومة الويب المتكاملة بأكملها. نتيجةً لذلك، من الجيد أن تكون الخطوة الوقائية الأولى هي التحقّق مما إذا كان متصفح المستخدم يمكنه استخدام WebGPU.
- للتحقّق مما إذا كان العنصر
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()
، هناك خيارات يمكن تمريرها هنا لاستخدامات أكثر تقدمًا، مثل تفعيل ميزات أجهزة معيّنة أو طلب حدود أعلى، ولكن تعمل الإعدادات التلقائية على نحو جيد لأغراضك.
ضبط مساحة العرض
والآن بعد أن حصلت على جهاز، هناك إجراء آخر يمكنك فعله إذا كنت تريد استخدامه لعرض أي شيء على الصفحة: وهو إعداد لوحة الرسم لتتمكن من استخدامها مع الجهاز الذي أنشأته للتو.
- لإجراء ذلك، عليك أولاً طلب
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 لتخزين بيانات الصور، ولكل زخرفة تنسيق يتيح لوحدة GPU معرفة كيفية ترتيب هذه البيانات في الذاكرة. لا تندرج تفاصيل طريقة عمل ذاكرة المظهر الخارجي ضمن هذا الدرس التطبيقي حول الترميز. من المهم معرفة أنّ سياق اللوحة يقدّم مواد للرسم عليها باستخدام الرمز البرمجي، ويمكن أن يؤثر التنسيق الذي تستخدمه في مدى كفاءة عرض اللوحة لهذه الصور. تعمل الأنواع المختلفة من الأجهزة بشكل أفضل عند استخدام تنسيقات زخرفة مختلفة، وإذا لم تستخدم التنسيق المفضل للجهاز، فقد يتسبب ذلك في ظهور نسخ إضافية من الذاكرة وراء الكواليس قبل عرض الصورة كجزء من الصفحة.
لحسن الحظ، لا داعي للقلق بشأن أيّ من ذلك لأنّ WebGPU يُعلمك بالتنسيق الذي يجب استخدامه للوحة. في جميع الحالات تقريبًا، تريد تمرير القيمة المعروضة من خلال استدعاء navigator.gpu.getPreferredCanvasFormat()
، كما هو موضح أعلاه.
محو لوحة الرسم
الآن بعد أن أصبح لديك جهاز وتم ضبط اللوحة عليه، يمكنك بدء استخدام الجهاز لتغيير محتوى اللوحة. للبدء، امسحها بلون واحد.
ولتنفيذ ذلك، أو أي شيء آخر في WebGPU، تحتاج إلى تقديم بعض الأوامر إلى وحدة معالجة الرسومات (GPU) لتوجِّهها إلى ما يجب فعله.
- لإجراء ذلك، اطلب من الجهاز إنشاء
GPUCommandEncoder
، وهو يقدّم واجهة لتسجيل أوامر وحدة معالجة الرسومات.
index.html
const encoder = device.createCommandEncoder();
ترتبط الأوامر التي تريد إرسالها إلى وحدة معالجة الرسومات بالعرض (في هذه الحالة، محو اللوحة)، لذا فإن الخطوة التالية هي استخدام encoder
لبدء Render Pass.
بطاقات العرض هي عند حدوث كل عمليات الرسم في 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 على المحور ص، والحافة العلوية هي +1 على المحور ص. وهذا يعني أنّ (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 لمساعدتك في فهم المشكلة.
بعد ذلك، حدِّد size للمخزن المؤقت بالبايت. يجب أن يتوفّر لديك مخزن مؤقت بحجم 48 بايت، ويمكنك تحديده من خلال ضرب حجم وحدة عائمة 32 بت ( 4 بايت) في عدد الأعداد العشرية في المصفوفة vertices
(12). ولحسن الحظ، تحسب TypedArrays قيمة byteLength لك، وبالتالي يمكنك استخدام ذلك عند إنشاء المخزن المؤقت.
وأخيرًا، عليك تحديد استخدام المخزن المؤقت. هذه علامة واحدة أو أكثر من علامات GPUBufferUsage
، مع دمج علامات متعددة مع عامل التشغيل |
( باتجاه البت OR). في هذه الحالة، يمكنك تحديد أنّك تريد استخدام المخزن المؤقت لبيانات رؤوس المضلّعات (GPUBufferUsage.VERTEX
) وأنّك تريد أيضًا أن تتمكّن من نسخ البيانات إليه (GPUBufferUsage.COPY_DST
).
كائن المخزن المؤقت الذي يتم إرجاعه إليك معتم - لا يمكنك (بسهولة) فحص البيانات التي يحتفظ بها. بالإضافة إلى ذلك، تكون معظم سمات GPUBuffer
غير قابلة للتغيير، ولا يمكنك تغيير حجمها بعد إنشائها، ولا يمكنك تغيير علامات الاستخدام. ما يمكنك تغييره هو محتوى ذاكرته.
عند إنشاء المخزن المؤقت في البداية، سيتم تهيئة الذاكرة التي يحتوي عليها على صفر. هناك عدة طرق لتغيير محتوياته، ولكن الطريقة الأسهل هي استدعاء device.queue.writeBuffer()
باستخدام TypedArray الذي تريد نسخه.
- لنسخ بيانات الرأس إلى ذاكرة المخزن المؤقت، أضف الرمز التالي:
index.html
device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);
تحديد تنسيق رأس الصفحة
أصبح لديك الآن مورد احتياطي يحتوي على بيانات رأس الصفحة، ولكن بقدر ما ترى وحدة معالجة الرسومات، فهي مجرد وحدة بايت من وحدات البايت. يجب تقديم المزيد من المعلومات إذا كنت تريد رسم أي شيء باستخدامه. يجب أن تكون قادرًا على إخبار WebGPU بالمزيد حول هيكل بيانات رأس الصفحة.
- حدِّد بنية بيانات رأس الصفحة باستخدام قاموس
GPUVertexBufferLayout
:
index.html
const vertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0, // Position, see vertex shader
}],
};
قد يكون هذا محيرًا بعض الشيء للوهلة الأولى، ولكن من السهل نسبيًا تحليله.
أوّل ما تقدّمه هو arrayStride
. هذا هو عدد وحدات البايت التي تحتاج وحدة معالجة الرسومات إلى تخطّيها للأمام في المخزن المؤقت عند البحث عن رأس الصفحة التالي. يتألّف كل رأس من رؤوس المربع من عددَين بنقطة عائمة 32 بت. كما ذكرنا سابقًا، يكون حجم العدد العشري 32 بت 4 بايت، وبالتالي فإن عدد عشري 8 بايت يكون 8 بايت.
يلي ذلك السمة attributes
، وهي صفيف. السمات هي أجزاء المعلومات الفردية التي تم ترميزها في كلّ رأس. تحتوي رؤوسك على سمة واحدة فقط (موضع الرأس)، لكن حالات الاستخدام الأكثر تقدمًا غالبًا ما تحتوي على رؤوس ذات سمات متعددة فيها، مثل لون الرأس أو الاتجاه الذي يشير إليه السطح الهندسي. ومع ذلك، هذا خارج نطاق هذا الدرس التطبيقي حول الترميز.
في السمة الواحدة، عليك أولاً تحديد format
للبيانات. ويرجع ذلك إلى قائمة من أنواع GPUVertexFormat
التي تصف كل نوع من بيانات رأس الصفحة التي يمكن أن تفهمها وحدة معالجة الرسومات. تحتوي رؤوسك على عددَين مثبَّتَين بسعة 32 بت لكلّ منهما، لذا يمكنك استخدام التنسيق float32x2
. إذا كانت بيانات الرأس تتألف بدلاً من ذلك من أربعة أعداد صحيحة غير موقَّعة 16 بت لكل منها، على سبيل المثال، يمكنك استخدام uint16x4
بدلاً من ذلك. هل لاحظت النمط؟
بعد ذلك، يوضّح offset
عدد وحدات البايت التي تبدأ فيها هذه السمة المحدّدة. لا داعي للقلق بشأن ذلك إلا إذا كان المخزن المؤقت يحتوي على أكثر من سمة واحدة، وهو ما لن يحدث خلال هذا البرنامج التعليمي.
أخيرًا، لديك shaderLocation
. ويكون هذا الرقم عبارة عن رقم عشوائي يتراوح بين 0 و15، ويجب أن يكون فريدًا لكل سمة تحدّدها. ويربط هذه السمة بإدخال معيّن في برنامج تظليل رؤوس المضلّعات، وسيتم التعرّف على ذلك في القسم التالي.
لاحظ أنه على الرغم من تحديد هذه القيم الآن، فأنت لا تمررها إلى WebGPU API في أي مكان حتى الآن. سنتناول ذلك قريبًا، ولكن من الأسهل التفكير في هذه القيم عند تحديد الرؤوس، لذا عليك إعدادها الآن لاستخدامها لاحقًا.
البدء باستخدام مواد التشويش
الآن لديك البيانات التي تريد عرضها، لكنك لا تزال بحاجة إلى إخبار وحدة معالجة الرسومات بكيفية معالجتها بالضبط. ويحدث جزء كبير من ذلك باستخدام أدوات تمييز الألوان.
Shaders هي برامج صغيرة تكتبها وتُنفذها على وحدة معالجة الرسومات. تعمل كل أداة تظليل على مرحلة مختلفة من البيانات: معالجة Vertex أو معالجة Fragment أو عملية Compute العامة. ونظرًا لأنّ هذه الأجهزة تستخدم وحدة معالجة الرسومات، فإنّها تتضمّن بنية أكثر صرامة من بنية رموز JavaScript العادية. ولكن هذه البنية تسمح بتنفيذها بسرعة كبيرة وبالتوازي.
تتم كتابة برامج التظليل في WebGPU بلغة تظليل تُعرف باسم WGSL (WebGPU Shading Language). تشبه WGSL لغة Rust من حيث البنية، وتتضمن ميزات تهدف إلى تسهيل وسرعة تنفيذ الأنواع الشائعة من عمل وحدة معالجة الرسومات (مثل العمليات الحسابية على المتجهات والمصفوفات). إنّ تعليم لغة التظليل بالكامل هو أكثر ما هو خارج نطاق هذا الدرس التطبيقي حول الترميز، ولكنّنا نأمل أن تتمكن من التعرّف على بعض الأساسيات أثناء التعرّف على بعض الأمثلة البسيطة.
يتم نقل أدوات التظليل نفسها إلى WebGPU كسلاسل.
- يمكنك إنشاء مكان لإدخال رمز أداة التظليل عن طريق نسخ ما يلي في الرمز أسفل
vertexBufferLayout
:
index.html
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
لإنشاء أدوات التظليل التي تطلبها device.createShaderModule()
، يمكنك تقديم label
وWGSL code
كسلسلة. (لاحظ أنك تستخدم الفواصل العليا المائلة هنا للسماح بالسلاسل متعددة الأسطر!) بعد إضافة بعض رموز WGSL صالحة، تعرض الدالة عنصر GPUShaderModule
يتضمّن النتائج المجمّعة.
تحديد أداة تظليل الرأس
ابدأ بأداة تظليل رأس الصفحة، لأنّ وحدة معالجة الرسومات تبدأ عندها أيضًا.
يتم تعريف برنامج تشفير قمة كدالّة، وتستدعي وحدة معالجة الرسومات هذه الدالة مرة واحدة لكلّ قمة في vertexBuffer
. بما أنّ دالة vertexBuffer
تحتوي على ستة مواضع (رؤوس)، يتم استدعاء الدالة التي تحددها ست مرات. في كل مرة يتم فيها استدعاء الدالة، يتم تمرير موضع مختلف عن vertexBuffer
إلى الدالة كوسيطة، وتكون وظيفة دالة برنامج تظليل رؤوس المضلّعات هي عرض موضع مناظر في مساحة المقطع.
من المهم أيضًا معرفة أنّه لن يتم استدعاء المراجعين بالترتيب التسلسلي. بدلاً من ذلك، تتفوّق وحدات معالجة الرسومات في تشغيل أدوات تظليل مثل هذه بالتوازي، ما قد يؤدي إلى معالجة مئات (أو حتى آلاف) الرؤوس في الوقت نفسه. وهذا جزء كبير من المسؤول عن السرعة المذهلة لوحدات معالجة الرسومات، غير أنها ذات بعض القيود. لضمان التوازي الشديد، لا يمكن أن تتواصل أدوات تظليل رأسك مع بعضها البعض. ويمكن لكل استدعاء أداة التظليل الاطّلاع على بيانات رأس واحد فقط في المرة الواحدة، ويمكنه فقط إخراج قيم رأس واحد.
في WGSL، يمكن تسمية دالة برنامج تشفير وحدات الرأس بأي اسم تريده، ولكن يجب أن تحتوي على @vertex
السمة أمامها للإشارة إلى مرحلة برنامج التشفير التي تمثّلها. يُشار إلى الدوالّ باستخدام الكلمة الرئيسية 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);
}
ومع ذلك، بما أنّ هذه الأنواع من عمليات الربط شائعة جدًا في برامج Shader، يمكنك أيضًا تمرير متجه الموضع كوسيطة أولى في اختصار مناسب ويؤدي ذلك إلى النتيجة نفسها.
- أعِد كتابة العبارة
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
. ويتضمّن ذلك أيضًا وحدة أداة التظليل وenterPoint، مثل مرحلة رأس الصفحة. البت الأخير هو تحديد 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- رسم شبكة
أولاً، نريد أن نهنئك على إنجازك. غالبًا ما يكون ظهور الأجزاء الهندسية الأولى على الشاشة أحد أصعب الخطوات في معظم واجهات برمجة تطبيقات GPU. يمكن تنفيذ كل ما تفعله من هنا في خطوات أصغر، ما يسهّل عليك التحقّق من مستوى تقدّمك أثناء التنفيذ.
في هذا القسم، ستتعرف على:
- كيفية تمرير المتغيرات (التي تُعرف باسم الزي الرسمي) إلى أداة التظليل من 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)
يستخدم الموارد الموجودة في مجموعة الربط هذه.
والآن أصبح المخزن المؤقت للعناصر الموحدة متاحًا لمخطّط التظليل.
- حدّث صفحتك، وبعد ذلك سيظهر لك شيء على النحو التالي:
رائع! أصبح مربعك الآن ربع الحجم الذي كان عليه من قبل! هذا ليس كثيرًا، ولكنّه يشير إلى أنّه تم تطبيق النموذج المتّسق وأنّه يمكن للظل الآن الوصول إلى حجم الشبكة.
معالجة الأشكال الهندسية في أداة التظليل
والآن بعد أن تمكنت من الإشارة إلى حجم الشبكة في أداة التظليل، يمكنك البدء في القيام ببعض الأعمال لمعالجة الشكل الهندسي الذي تعرضه لتناسب نمط الشبكة الذي تريده. للقيام بذلك، فكر بالضبط ما تريد تحقيقه.
عليك تقسيم اللوحة بشكل مفاهيمي إلى خلايا فردية. من أجل الحفاظ على اصطلاح زيادة المحور س كلما تحركت اليمين ويزداد المحور ص أثناء تحركك لأعلى، لنفترض أن الخلية الأولى تقع في أسفل الجانب الأيسر من لوحة الرسم. يمنحك هذا تخطيطًا يبدو كالتالي، مع وجود الهندسة المربعة الحالية في المنتصف:
يتمثل التحدي الذي يواجهك في العثور على طريقة في أداة التظليل تتيح لك وضع الشكل الهندسي للمربع في أي من تلك الخلايا بناءً على إحداثيات الخلية.
أولاً، يمكنك ملاحظة أنّ مربّعك غير محاذٍ بشكل جيد لأيّ من الخلايا لأنّه تم تحديده ليحيط بمركز اللوحة. يجب تحريك المربّع بمقدار نصف خلية حتى يتماشى بشكل جيد مع الخلايا.
تتمثل إحدى الطرق التي يمكنك من خلالها إصلاح هذا في تحديث المخزن المؤقت للرأس للمربع. من خلال نقل الرؤوس لكي يكون الزاوية السفلية اليسرى على (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)، وهكذا إلى (15, 15)، ولكن لا تلائم سوى الخلايا الأربع الأولى اللوحة. لإنشاء الشبكة التي تريدها، يجب تحويل instance_index
بحيث يتم ربط كل فهرس بخلية فريدة داخل شبكتك، كما يلي:
إنّ العملية الحسابية لذلك بسيطة إلى حدٍ ما. بالنسبة إلى قيمة X لكل خلية، تريد modulo للسمة 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()
في أداة التظليل.
بعد إجراء ذلك، من المفترض أن تتمكّن من إعادة تحميل الصفحة ورؤية النمط يظهر في الشبكة.
استخدام نمط "تبديل التخزين المؤقت"
عادةً ما تستخدم معظم عمليات المحاكاة، مثل تلك التي تبنيها، نسختين على الأقل من حالتها. في كل خطوة من خطوات المحاكاة، يقرأون من نسخة من الحالة ويكتبون إلى النسخة الأخرى. بعد ذلك، في الخطوة التالية، اقلبها واقرأ من الحالة التي كتب إليها سابقًا. يشار إلى هذا عادةً باسم نمط بينج بونغ لأن أحدث إصدار من الولاية يرتد ذهابًا وإيابًا بين الولاية ونسخ كل خطوة.
لماذا يُعد ذلك ضروريًا؟ إليك مثالاً مبسطًا: لنفترض أنّك تكتب محاكاة بسيطة جدًا تنقل فيها أيّ كتل نشطة خلية واحدة إلى اليمين في كلّ خطوة. لسهولة الفهم، يمكنك تحديد البيانات والمحاكاة في 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. تشغيل المحاكاة
سنتحدّث الآن عن آخر جزء رئيسي من اللغز، وهو إجراء محاكاة Game of Life باستخدام أداة تظليل الحوسبة.
أخيرًا، استخدِم أدوات تظليل الحوسبة.
لقد تعرّفت بالفعل على أدوات التظليل الحوسبية خلال هذا الدرس التطبيقي حول الترميز، ولكن ما هي بالضبط؟
تشبه برامج تظليل الحوسبة برامج تظليل رؤوس المقاطع وبرامج تظليل الشرائح من حيث أنّها مصمّمة للتشغيل مع توازٍ كبير على وحدة معالجة الرسومات، ولكن على عكس مرحلتَي تظليل الشرائح الأخرى، لا تتضمّن هذه البرامج مجموعة محدّدة من المدخلات والمخرجات. تتم قراءة البيانات وكتابتها حصريًا من المصادر التي تختارها، مثل ذاكرات التخزين المؤقت. وهذا يعني أنّه بدلاً من التنفيذ مرة واحدة لكلّ رأس أو مثيل أو بكسل، عليك تحديد عدد عمليات استدعاء دالة برنامج التظليل التي تريدها. بعد ذلك، عند تشغيل برنامج التظليل، يتم إعلامك بالطلب الذي تتم معالجته، ويمكنك تحديد البيانات التي ستتمكّن من الوصول إليها والعمليات التي ستنفّذها من هناك.
يجب إنشاء أدوات تظليل الحوسبة في وحدة أداة التظليل، تمامًا مثل أدوات تظليل الأجزاء الرأسية، لذا أضف ذلك إلى التعليمات البرمجية للبدء. كما يمكنك أن تتوقّع، يجب وضع السمة @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() {
}`
});
نظرًا لاستخدام وحدات معالجة الرسومات بشكل متكرر مع الرسومات الثلاثية الأبعاد، يتم تنظيم أدوات تظليل الحوسبة بحيث يمكنك طلب استدعاء أداة التظليل لعدد معيّن من المرات على طول المحور "س" و"ص" و"ع". يتيح لك ذلك إرسال عمل يتطابق مع شبكة ثنائية أو ثلاثية الأبعاد بسهولة كبيرة، ما يُعدّ أمرًا رائعًا لحالة الاستخدام. تريد استدعاء هذا المخطِّط GRID_SIZE
مرة GRID_SIZE
مرة، مرة واحدة لكل خلية من المحاكاة.
ونظرًا لطبيعة بنية أجهزة وحدة معالجة الرسومات، يتم تقسيم هذه الشبكة إلى مجموعات عمل. مجموعة العمل لها حجم X وY وZ، ورغم أن الأحجام يمكن أن تكون 1 لكل منها، فغالبًا ما تكون هناك فوائد أداء لجعل مجموعات العمل أكبر قليلاً. بالنسبة إلى برنامج تشويش الصورة، اختَر حجم مجموعة عمل عشوائيًا إلى حدٍ ما يبلغ 8 مرات 8. ويكون ذلك مفيدًا لتتبُّعه في رمز JavaScript.
- حدِّد ثابتًا لحجم مجموعة العمل، على النحو التالي:
index.html
const WORKGROUP_SIZE = 8;
عليك أيضًا إضافة حجم مجموعة العمل إلى وظيفة أداة التظليل نفسها، ويمكنك استخدام القيم الحرفية لنموذج JavaScript كي تتمكّن بسهولة من استخدام الثابت الذي حدّدته للتو.
- أضِف حجم مجموعة العمل إلى دالة برنامج التظليل، على النحو التالي:
index.html (طلب إنشاء وحدة shader)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
هذا يخبر أداة التظليل بأن العمل المنجز باستخدام هذه الدالة تم في مجموعات (8 × 8 × 1). (أي محور لا تُحدِّده يكون تلقائيًا 1، ولكن عليك تحديد محور X على الأقل).
كما هو الحال مع مراحل أداة التظليل الأخرى، هناك مجموعة متنوعة من قيم @builtin
التي يمكنك قبولها كإدخال في وظيفة أداة تظليل الحوسبة لإخبارك بالاستدعاء الذي تستخدمه وتحديد العمل الذي تريد تنفيذه.
- أضِف قيمة
@builtin
، على النحو التالي:
index.html (Compute createShaderModule call)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
يمكنك النقر على global_invocation_id
المضمّن، وهو متجه ثلاثي الأبعاد من الأعداد الصحيحة غير الموقَّعة، والذي يخبرك بمكانك في شبكة استدعاءات أداة التظليل. يتم تشغيل هذا المخطِّط اللوني مرّة واحدة لكل خلية في الشبكة. ستحصل على أرقام مثل (0, 0, 0)
و(1, 0, 0)
و(1, 1, 0)
... وصولاً إلى (31, 31, 0)
، ما يعني أنّه يمكنك التعامل معها على أنّها فهرس الخلية التي ستُجري عليها العمليات الحسابية.
يمكن لأدوات تظليل الحوسبة أيضًا استخدام الزي الرسمي، الذي تستخدمه تمامًا كما في أدوات تظليل الأجزاء والرأس.
- استخدِم متغيرًا متّسقًا مع برنامج التظليل الحسابي لمعرفة حجم الشبكة، على النحو التالي:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f; // New line
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
وكما هو الحال في أداة تظليل رأس الرأس، فإنك تعرض أيضًا حالة الخلية كمورد مؤقت للتخزين. لكن في هذه الحالة، أنت بحاجة إلى اثنين منهم! بما أنّ وحدات معالجة الرسومات الحسابية لا تتضمّن مخرجًا مطلوبًا، مثل موضع رأس القطعة أو لون القطعة، فإنّ كتابة القيم في ذاكرة تخزين أو نسيج هي الطريقة الوحيدة للحصول على نتائج من وحدة معالجة الرسومات الحسابية. استخدم طريقة بينغ بونغ التي تعلمتها سابقًا؛ فلديك مخزن مؤقت واحد للتخزين يغذي بالحالة الحالية للشبكة، وآخر تكتب له الحالة الجديدة للشبكة.
- اعرض مدخلات الخلية وحالة الإخراج كوحدات تخزين مؤقتة، على النحو التالي:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
يُرجى العِلم أنّه يتم تحديد المخزن المؤقت الأول باستخدام var<storage>
، ما يجعله للقراءة فقط، ولكن يتم تعريف المخزن المؤقت الثاني باستخدام var<storage, read_write>
. ويتيح لك ذلك القراءة والكتابة في المخزن المؤقت، باستخدام هذا المخزن المؤقت كإخراج لأداة تظليل الحوسبة. (لا يتوفّر وضع تخزين للكتابة فقط في WebGPU).
بعد ذلك، يجب أن يكون لديك طريقة لتعيين فهرس الخلايا في صفيف التخزين الخطي. وهذا في الأساس هو عكس ما فعلته في أداة تظليل الرأس، حيث اتخذت instance_index
الخطي وربطته بخلية شبكة ثنائية الأبعاد. (تذكير: كانت الخوارزمية التي استخدمتها هي vec2f(i % grid.x, floor(i / grid.x))
.)
- اكتب دالة للانتقال في الاتجاه الآخر. تأخذ قيمة Y للخلية، وتضربها في عرض الشبكة، ثم تضيف قيمة X للخلية.
index.html (طلب Compute createShaderModule)
@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 (طلب إنشاء وحدة shader)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines. Flip the cell state every step.
if (cellStateIn[cellIndex(cell.xy)] == 1) {
cellStateOut[cellIndex(cell.xy)] = 0;
} else {
cellStateOut[cellIndex(cell.xy)] = 1;
}
}
هذا كل شيء بالنسبة إلى أداة تظليل الحوسبة في الوقت الحالي! ولكن قبل أن تظهر لك النتائج، عليك إجراء بعض التغييرات الإضافية.
استخدام تنسيقات مجموعات الربط ومسار الإحالة الناجحة
من الأمور التي قد تلاحظها في برنامج التظليل أعلاه أنّه يستخدم إلى حد كبير المدخلات نفسها (المتجانسات ووحدات تخزين المؤقت) التي تستخدمها مسار المعالجة. قد تعتقد أنّه يمكنك ببساطة استخدام مجموعات الربط نفسها والانتهاء من ذلك، أليس كذلك؟ والخبر السار هو أنه يمكنك ذلك! يتطلب ذلك إجراء المزيد من الإعدادات اليدوية.
عندما تنشئ مجموعة ربط، عليك توفير GPUBindGroupLayout
. في السابق، كنت تحصل على هذا التنسيق من خلال استدعاء getBindGroupLayout()
في مسار التقديم، والذي أنشأه بدوره تلقائيًا لأنّك قدّمت layout: "auto"
عند إنشائه. تعمل هذه الطريقة بشكل جيد عندما تستخدم مسارًا واحدًا فقط، ولكن إذا كان لديك العديد من المسارات التي تريد مشاركة الموارد، فأنت بحاجة إلى إنشاء التخطيط بشكل صريح، ثم تقديمه إلى كل من مجموعة الربط والمسارات.
للمساعدة في فهم السبب، ضع في اعتبارك ذلك: في مسارات العرض، تستخدم مخزنًا مؤقتًا موحدًا ومخزنًا مؤقتًا واحدًا للتخزين، ولكن في أداة تظليل الحوسبة التي كتبتها للتو، تحتاج إلى مخزن مؤقت ثانٍ للتخزين. بما أنّ أداتَي التظليل تستخدمان قيم @binding
نفسها للمخزن المؤقت الموحَّد وأول مخزن تخزين مؤقت، يمكنك مشاركة تلك القيم بين مسارات التعلّم، ويتجاهل مسار العرض المخزن المؤقت الثاني للتخزين، الذي لا يستخدمه. تحتاج إلى إنشاء تنسيق يصف جميع الموارد الموجودة في مجموعة الربط، وليس فقط الموارد التي يستخدمها مسار معين.
- لإنشاء هذا التنسيق، يمكنك طلب
device.createBindGroupLayout()
:
index.html
// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
label: "Cell Bind Group Layout",
entries: [{
binding: 0,
// Add GPUShaderStage.FRAGMENT here if you are using the `grid` uniform in the fragment shader.
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: {} // Grid uniform buffer
}, {
binding: 1,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: { type: "read-only-storage"} // Cell state input buffer
}, {
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "storage"} // Cell state output buffer
}]
});
تتشابه هذه البنية مع إنشاء مجموعة الربط نفسها، حيث تصف قائمة entries
. والفرق هو أنّك تصف نوع المرجع الذي يجب أن يكون عليه الإدخال وطريقة استخدامه بدلاً من تقديم المرجع نفسه.
في كل إدخال، تمنح رقم binding
للمورد، والذي يتطابق (كما تعلّمت عند إنشاء مجموعة الربط) مع القيمة @binding
في أدوات التظليل. يمكنك أيضًا تقديم visibility
، وهي GPUShaderStage
علامات تشير إلى مراحل التظليل التي يمكنها استخدام المورد. وتريد أن تكون إمكانية الوصول إلى كل من المخزن المؤقت المنتظم والأول للتخزين في رأس كل من أدوات التظليل والحوسبة، ولكن لا يجب الوصول إلى المخزن المؤقت الثاني للتخزين إلا من خلال أدوات تظليل الحوسبة.
وأخيرًا، يمكنك الإشارة إلى نوع المورد الذي يتم استخدامه. يختلف هذا مفتاح القاموس بناءً على ما تريد إظهاره. في هذه الحالة، تكون جميع الموارد الثلاثة هي ذاكرة تخزين مؤقت، لذا يمكنك استخدام المفتاح buffer
لتحديد الخيارات لكل منها. تتضمن الخيارات الأخرى عناصر مثل texture
أو sampler
، ولكنك لست بحاجة إليها هنا.
في قاموس المخزن المؤقت، يمكنك ضبط خيارات مثل type
المخزن المؤقت المستخدَم. القيمة التلقائية هي "uniform"
، لذا يمكنك ترك القاموس فارغًا لربط القيمة 0. (يجب ضبط buffer: {}
على الأقلّ لكي يتم التعرّف على الإدخال على أنّه ذاكرة تخزين مؤقت). الربط 1 هو النوع "read-only-storage"
لأنّك لا تستخدمه مع إذن الوصول read_write
في أداة التظليل، في حين أنّ الربط 2 نوع "storage"
لأنّك تستخدمه مع إذن الوصول read_write
.
بعد إنشاء bindGroupLayout
، يمكنك تمريرها عند إنشاء مجموعات الربط بدلاً من طلب مجموعة الربط من مسار الإحالة الناجحة. يعني ذلك أنّك بحاجة إلى إضافة إدخال جديد إلى وحدة تخزين مؤقتة في كل مجموعة ربط لمطابقة التنسيق الذي حدّدته للتو.
- عدِّل عملية إنشاء مجموعة الربط، على النحو التالي:
index.html
// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[1] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[0] }
}],
}),
];
والآن بعد أن تم تعديل مجموعة الربط لاستخدام تخطيط مجموعة الربط الصريح هذا، عليك تعديل مسار العرض لاستخدام العنصر نفسه.
- إنشاء
GPUPipelineLayout
index.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
تخطيط مسار الإحالة الناجحة هو قائمة بتنسيقات مجموعات الربط (في هذه الحالة، لديك تنسيق واحد) يستخدمها مسار إحالة ناجحة واحد أو أكثر. يجب أن يتوافق ترتيب تصميمات مجموعة الربط في المصفوفة مع سمات @group
في أدوات التظليل. (يعني ذلك أنّ bindGroupLayout
مرتبط بـ @group(0)
).
- بعد الحصول على تنسيق مسار العرض، عدِّل مسار العرض لاستخدامه بدلاً من
"auto"
.
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: pipelineLayout, // Updated!
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
إنشاء مسار الحوسبة
مثلما تحتاج إلى مسار عرض لاستخدام أدوات تظليل رؤوس الأجزاء وأجزاء الرأس، ستحتاج إلى مسار حوسبة لاستخدام أداة تظليل الحوسبة. لحسن الحظّ، تُعتبر مسارات الحوسبة أقل تعقيدًا بكثير من مسارات العرض، إذ لا تتوفّر بها أي حالة لضبطها، بل فقط أداة التظليل والتنسيق.
- أنشئ مسار حوسبة باستخدام التعليمة البرمجية التالية:
index.html
// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
label: "Simulation pipeline",
layout: pipelineLayout,
compute: {
module: simulationShaderModule,
entryPoint: "computeMain",
}
});
يُرجى ملاحظة أنّك تُدخل pipelineLayout
الجديد بدلاً من "auto"
، تمامًا كما هو الحال في مسار المعالجة المُعدَّل للعرض، ما يضمن أنّه يمكن لكلٍّ من مسار المعالجة المُعدَّل للعرض ومسار المعالجة الحسابية استخدام مجموعات الربط نفسها.
احتساب البطاقات
ينقلك ذلك إلى نقطة الاستفادة فعليًا من مسار حساب الأداء. بما أنّك تُجري عملية التقديم في عملية تقديم، يمكنك على الأرجح توقّع أنّك بحاجة إلى إجراء عمل حسابي في عملية حسابية. يمكن أن يتمّ تنفيذ عمل الحساب والعرض في برنامج ترميز الأوامر نفسه، لذا عليك ترتيب دالة 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
في أداة التظليل.
إذا أردت تنفيذ أداة التظليل 32x32 مرة لتغطية شبكتك بأكملها، وكان حجم مجموعة العمل 8x8، فستحتاج إلى إرسال مجموعات العمل 4x4 (4 * 8 = 32). ولهذا السبب تقسم حجم الشبكة على حجم مجموعة العمل وتمرر هذه القيمة إلى dispatchWorkgroups()
.
يمكنك الآن إعادة تحميل الصفحة مرة أخرى، ومن المفترض أن تلاحظ أنّ الشبكة تنقلب مع كل تعديل.
تطبيق الخوارزمية للعبة Game of Life
قبل تحديث أداة تظليل الحوسبة لتنفيذ الخوارزمية النهائية، يجب الرجوع إلى الرمز الذي يُهيئ محتوى المخزن المؤقت للتخزين وتحديثه لإنتاج مخزن مؤقت عشوائي عند كل تحميل للصفحة. (لا تُعدّ الأنماط العادية نقاط بداية مثيرة للاهتمام في لعبة "البقاء على قيد الحياة"). يمكنك ترتيب القيم عشوائيًا كيفما تريد، ولكن هناك طريقة سهلة للبدء تؤدي إلى نتائج معقولة.
- لبدء كل خلية في حالة عشوائية، عدِّل عملية إعداد
cellStateArray
إلى الرمز التالي:
index.html
// Set each cell to a random state, then copy the JavaScript array
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
يمكنك الآن تنفيذ منطق محاكاة لعبة الحياة. بعد كل ما تطلبه للوصول إلى هنا، قد لا يكون رمز أداة التظليل بسيطًا للغاية.
ينبغي أولًا أن تعرف عدد العناصر المجاورة النشطة لأي خليةٍ ما. ولا يهمّك أيها نشط، بل العدد فقط.
- لتسهيل الحصول على بيانات الخلية المجاورة، أضف الدالة
cellActive
التي تعرض القيمةcellStateIn
للإحداثي المحدد.
index.html (طلب Compute createShaderModule)
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 (طلب إنشاء وحدة shader)
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، وهي مناسبة لهذا المنطق.
- نفِّذ مبدأ Game of Life على النحو التالي:
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. تهانينا!
لقد أنشأت نسخة من لعبة محاكاة Game of Life الكلاسيكية من Conway التي تعمل بالكامل على وحدة معالجة الرسومات باستخدام WebGPU API.
الخطوات التالية
- راجِع نماذج WebGPU.
مراجع إضافية
- WebGPU: جميع النوى، بدون استخدام مساحة العرض
- وحدة معالجة الرسومات الأولية (WebGPU)
- أساسيات WebGPU
- أفضل الممارسات المتعلّقة بواجهة برمجة التطبيقات WebGPU