1. מבוא

מה זה WebGPU?
WebGPU הוא API חדש ומודרני לגישה ליכולות של ה-GPU באפליקציות אינטרנט.
Modern API
לפני WebGPU, הייתה WebGL, שהציעה קבוצת משנה של התכונות של WebGPU. היא אפשרה ליצור סוג חדש של תוכן עשיר לאינטרנט, ומפתחים יצרו איתה דברים מדהימים. עם זאת, הוא התבסס על API OpenGL ES 2.0, שהושק בשנת 2007 והתבסס על API OpenGL, שהוא אפילו ישן יותר. במהלך התקופה הזו, חל שיפור משמעותי ב-GPU, וגם בממשקי ה-API המקוריים שמשמשים ליצירת אינטראקציה איתם, עם Direct3D 12, Metal ו-Vulkan.
WebGPU מביא את ההתקדמות של ממשקי ה-API המודרניים האלה לפלטפורמת האינטרנט. הוא מתמקד בהפעלת תכונות של GPU בפלטפורמות שונות, תוך הצגת API שנראה טבעי באינטרנט ופחות מפורט מחלק מממשקי ה-API המקוריים שהוא מבוסס עליהם.
Rendering
יחידות GPU קשורות בדרך כלל לעיבוד מהיר של גרפיקה מפורטת, ו-WebGPU לא שונה. יש לו את התכונות הנדרשות לתמיכה בטכניקות רבות של רינדור שפופולריות היום, גם במעבדים גרפיים במחשבים וגם במעבדים גרפיים בניידים. הוא גם מאפשר להוסיף תכונות חדשות בעתיד ככל שהיכולות של החומרה ממשיכות להתפתח.
Compute
בנוסף לעיבוד, WebGPU מאפשר ל-GPU לבצע עומסי עבודה כלליים ומקבילים מאוד. אפשר להשתמש בשיידרים לחישוב באופן עצמאי, בלי רכיב עיבוד, או כחלק משולב בצינור העיבוד.
ב-codelab של היום תלמדו איך לנצל את יכולות העיבוד והחישוב של WebGPU כדי ליצור פרויקט פשוט למתחילים.
מה תפַתחו
ב-Codelab הזה תבנו את משחק החיים של קונוויי באמצעות WebGPU. האפליקציה שלכם:
- שימוש ביכולות הרינדור של WebGPU כדי לצייר גרפיקה דו-ממדית פשוטה.
- משתמשים ביכולות החישוב של WebGPU כדי לבצע את הסימולציה.

המשחק 'החיים' הוא מה שנקרא אוטומט תאי, שבו רשת של תאים משנה את המצב שלה לאורך זמן על סמך קבוצה מסוימת של כללים. במשחק החיים, התאים הופכים לפעילים או ללא פעילים בהתאם למספר התאים השכנים הפעילים שלהם, מה שמוביל לדפוסים מעניינים שמשתנים בזמן הצפייה.
מה תלמדו
- איך מגדירים WebGPU ומגדירים בד ציור.
- איך לצייר גיאומטריה פשוטה דו-ממדית.
- איך משתמשים ב-vertex וב-fragment shaders כדי לשנות את מה שמציירים.
- איך משתמשים ב-compute shaders כדי לבצע סימולציה פשוטה.
ה-Codelab הזה מתמקד בהצגת המושגים הבסיסיים שמאחורי WebGPU. המאמר לא נועד להיות סקירה מקיפה של ה-API, והוא לא כולל (או דורש) נושאים קשורים שחוזרים על עצמם לעיתים קרובות, כמו מתמטיקה של מטריצות תלת-ממדיות.
מה תצטרכו
- גרסה עדכנית של Chrome (גרסה 113 ואילך) ב-ChromeOS, macOS או Windows. WebGPU הוא API חוצה-דפדפנים ובפלטפורמות שונות, אבל הוא עדיין לא הושק בכל מקום.
- ידע ב-HTML, ב-JavaScript ובכלי הפיתוח ל-Chrome.
לא נדרש ידע בממשקי API אחרים לגרפיקה, כמו WebGL, Metal, Vulkan או Direct3D, אבל אם יש לכם ניסיון עם ממשקים כאלה, סביר להניח שתבחינו בהרבה קווי דמיון ל-WebGPU, שיעזרו לכם להתחיל ללמוד את הנושא.
2. להגדרה
קבלת הקוד
ל-codelab הזה אין תלות בשום דבר, והוא כולל הסבר מפורט על כל השלבים שנדרשים ליצירת אפליקציית WebGPU, כך שלא צריך קוד כדי להתחיל. עם זאת, יש דוגמאות עובדות שיכולות לשמש כנקודות ביקורת בכתובת https://github.com/GoogleChromeLabs/your-first-webgpu-app-codelab. אם נתקעתם, תוכלו לעיין בהם.
שימוש ב-Play Console
WebGPU הוא API מורכב למדי עם הרבה כללים שמבטיחים שימוש נכון. מה שגרוע יותר, בגלל אופן הפעולה של ה-API, הוא לא יכול להעלות חריגים רגילים של JavaScript עבור הרבה שגיאות, ולכן קשה יותר לזהות בדיוק מאיפה הבעיה מגיעה.
תיתקלו בבעיות כשמפתחים באמצעות WebGPU, במיוחד אם אתם מתחילים, וזה בסדר גמור! המפתחים של ה-API מודעים לאתגרים שקשורים לפיתוח GPU, והם השקיעו מאמצים רבים כדי להבטיח שבכל פעם שקוד WebGPU יגרום לשגיאה, תקבלו בחזרה הודעות מפורטות ומועילות במסוף המפתחים, שיעזרו לכם לזהות ולתקן את הבעיה.
מומלץ להשאיר את המסוף פתוח בזמן שאתם עובדים על כל אפליקציית אינטרנט, אבל זה חשוב במיוחד במקרה הזה.
3. אתחול WebGPU
מתחילים עם <canvas>
אפשר להשתמש ב-WebGPU בלי להציג שום דבר על המסך אם רוצים להשתמש בו רק לחישובים. אבל אם רוצים לעבד משהו, כמו שנעשה ב-codelab, צריך קנבס. אז זה מקום טוב להתחיל בו!
יוצרים מסמך HTML חדש עם רכיב <canvas> יחיד, וגם תג <script> שבו מבצעים שאילתה על רכיב ה-canvas. (או משתמשים ב- 00-starter-page.html).
- יוצרים קובץ
index.htmlעם הקוד הבא:
index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WebGPU Life</title>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script type="module">
const canvas = document.querySelector("canvas");
// Your WebGPU code will begin here!
</script>
</body>
</html>
בקשת מתאם ומכשיר
עכשיו אפשר להתחיל לעבוד עם WebGPU. קודם כל, חשוב לזכור שייקח זמן עד שממשקי API כמו WebGPU יתעדכנו בכל המערכת האקולוגית של האינטרנט. לכן, כדאי קודם לבדוק אם הדפדפן של המשתמש יכול להשתמש ב-WebGPU.
- כדי לבדוק אם האובייקט
navigator.gpu, שמשמש כנקודת הכניסה ל-WebGPU, קיים, מוסיפים את הקוד הבא:
index.html
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
מומלץ להודיע למשתמש אם WebGPU לא זמין, על ידי הגדרת דף שיחזור למצב שלא משתמש ב-WebGPU. (אולי אפשר להשתמש ב-WebGL במקום?) אבל לצורך ה-Codelab הזה, פשוט צריך להקפיץ הודעת שגיאה כדי שהקוד לא ימשיך לפעול.
אחרי שמוודאים שהדפדפן תומך ב-WebGPU, השלב הראשון באתחול WebGPU באפליקציה הוא לבקש GPUAdapter. אפשר לחשוב על מתאם כעל הייצוג של WebGPU של רכיב ספציפי של חומרת GPU במכשיר.
- כדי לקבל מתאם, משתמשים בשיטה
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() כדי לציין אם אתם רוצים להשתמש בחומרה עם צריכת חשמל נמוכה או עם ביצועים גבוהים במכשירים עם כמה מעבדי GPU (כמו במחשבים ניידים מסוימים).
אחרי שיש לכם מתאם, השלב האחרון לפני שמתחילים לעבוד עם ה-GPU הוא לבקש GPUDevice. המכשיר הוא הממשק העיקרי שדרכו מתבצעת רוב האינטראקציה עם ה-GPU.
- אפשר לקבל את המכשיר באמצעות קריאה ל-
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 משתמש בהם כדי לאחסן נתוני תמונות, ולכל מרקם יש פורמט שמאפשר ל-GPU לדעת איך הנתונים האלה מסודרים בזיכרון. הסבר מפורט על אופן הפעולה של זיכרון הטקסטורה חורג מהיקף ה-codelab הזה. חשוב לדעת שההקשר של אזור הציור מספק טקסטורות שהקוד יכול לצייר לתוכן, והפורמט שבו משתמשים יכול להשפיע על היעילות שבה התמונות מוצגות באזור הציור. סוגים שונים של מכשירים פועלים בצורה הכי טובה כשמשתמשים בפורמטים שונים של טקסטורה, ואם לא משתמשים בפורמט המועדף של המכשיר, יכול להיות שייווצרו עותקים נוספים של הזיכרון ברקע לפני שהתמונה תוצג כחלק מהדף.
למזלכם, אתם לא צריכים לדאוג יותר מדי לגבי כל זה, כי WebGPU מציין באיזה פורמט להשתמש עבור בד הציור. ברוב המקרים, כדאי להעביר את הערך שמוחזר מקריאה ל- navigator.gpu.getPreferredCanvasFormat(), כמו שמוצג למעלה.
ניקוי ה-Canvas
עכשיו שיש לכם מכשיר והגדרתם אותו בלוח, אתם יכולים להתחיל להשתמש במכשיר כדי לשנות את התוכן בלוח. כדי להתחיל, מנקים אותו עם צבע אחיד.
כדי לעשות את זה – או כל דבר אחר ב-WebGPU – צריך לספק ל-GPU כמה פקודות שמנחות אותו מה לעשות.
- כדי לעשות את זה, המכשיר יוצר
GPUCommandEncoder, שמספק ממשק להקלטת פקודות GPU.
index.html
const encoder = device.createCommandEncoder();
הפקודות שרוצים לשלוח ל-GPU קשורות לרינדור (במקרה הזה, ניקוי של אזור הציור), ולכן השלב הבא הוא להשתמש ב-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. כדי להשתמש ב-Render passes, צריך לספק GPUTextureView במקום GPUTexture, כדי לציין לאילו חלקים של הטקסטורה לבצע רנדר. ההגדרה הזו חשובה רק בתרחישי שימוש מתקדמים יותר, ולכן כאן קוראים ל-createView() ללא ארגומנטים בטקסטורה, כדי לציין שרוצים שמסלול העיבוד ישתמש בטקסטורה כולה.
צריך גם לציין מה רוצים ששלב העיבוד יעשה עם הטקסטורה כשהוא מתחיל וכשהוא מסתיים:
- ערך
loadOpשל"clear"מציין שרוצים לנקות את הטקסטורה כשתהליך העיבוד מתחיל. - ערך
storeOpשל"store"מציין שרוצים לשמור את התוצאות של כל ציור שבוצע במהלך שלב העיבוד לטקסטורה אחרי ששלב העיבוד מסתיים.
אחרי שתהליך הרינדור יתחיל, לא צריך לעשות כלום. לפחות בינתיים. הפעולה של התחלת שלב הרינדור עם loadOp: "clear" מספיקה כדי לנקות את תצוגת המרקם ואת הקנבס.
- כדי לסיים את שלב הרינדור, מוסיפים את הקריאה הבאה מיד אחרי
beginRenderPass():
index.html
pass.end();
חשוב לדעת שביצוע השיחות האלה לא גורם ל-GPU לעשות משהו בפועל. הם רק מקליטים פקודות ל-GPU כדי לבצע אותן מאוחר יותר.
- כדי ליצור
GPUCommandBuffer, קוראים ל-finish()במקודד הפקודות. מאגר הפקודות הוא נקודת אחיזה אטומה לפקודות המתועדות.
index.html
const commandBuffer = encoder.finish();
- שולחים את מאגר הפקודות ל-GPU באמצעות
queueשלGPUDevice. התור מבצע את כל הפקודות של ה-GPU, כדי לוודא שהביצוע שלהן מסודר ומסונכרן בצורה נכונה. השיטהsubmit()של התור מקבלת מערך של מאגרי פקודות, אבל במקרה הזה יש רק אחד.
index.html
device.queue.submit([commandBuffer]);
אחרי ששולחים את מאגר פקודות, אי אפשר להשתמש בו שוב, ולכן אין צורך לשמור אותו. אם רוצים לשלוח עוד פקודות, צריך ליצור עוד מאגר פקודות. לכן, די נפוץ לראות את שני השלבים האלה משולבים לשלב אחד, כמו בדפי הדוגמה של ה-codelab הזה:
index.html
// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);
אחרי ששולחים את הפקודות ל-GPU, צריך לאפשר ל-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 }הוא ערך ברירת המחדל, שחור שקוף.
בדוגמאות הקוד ובצילומי המסך ב-codelab הזה נעשה שימוש בכחול כהה, אבל אתם יכולים לבחור כל צבע שתרצו.
- אחרי שבוחרים צבע, טוענים מחדש את הדף. הצבע שבחרתם אמור להופיע באזור העריכה.

4. ציור גיאומטריה
בסוף הקטע הזה, האפליקציה תצייר גיאומטריה פשוטה על בד הציור: ריבוע צבעוני. חשוב לדעת שייראה כאילו נדרשת הרבה עבודה כדי להפיק פלט פשוט כזה, אבל זה בגלל ש-WebGPU נועד לעבד הרבה גיאומטריה בצורה יעילה מאוד. תופעת לוואי של היעילות הזו היא שביצוע פעולות פשוטות יחסית עשוי להרגיש קשה באופן חריג, אבל זה מה שצריך לצפות לו כשמשתמשים ב-API כמו WebGPU – רוצים לבצע פעולה קצת יותר מורכבת.
הסבר על אופן הציור של מעבדים גרפיים
לפני שמבצעים עוד שינויים בקוד, כדאי לעשות סקירה כללית מהירה ופשוטה של האופן שבו מעבדים גרפיים יוצרים את הצורות שרואים במסך. (אם אתם כבר מכירים את העקרונות הבסיסיים של עיבוד באמצעות GPU, אתם יכולים לדלג אל הקטע 'הגדרת קודקודים').
בניגוד לממשק API כמו Canvas 2D שיש בו הרבה צורות ואפשרויות מוכנות לשימוש, ה-GPU שלכם מתמודד רק עם כמה סוגים שונים של צורות (או פרימיטיבים, כמו שהם נקראים ב-WebGPU): נקודות, קווים ומשולשים. לצורך ה-codelab הזה, תשתמשו רק במשולשים.
מעבדי GPU פועלים כמעט רק עם משולשים, כי למשולשים יש הרבה תכונות מתמטיות טובות שמקלות על העיבוד שלהם בצורה צפויה ויעילה. כמעט כל מה שמציירים באמצעות ה-GPU צריך להיות מחולק למשולשים לפני שה-GPU יכול לצייר אותו, והמשולשים האלה צריכים להיות מוגדרים באמצעות נקודות הפינה שלהם.
הנקודות האלה, או הקודקודים, מוגדרות במונחים של ערכי X, Y ו-Z (עבור תוכן תלת-ממדי) שמגדירים נקודה במערכת צירים קרטזית שמוגדרת על ידי WebGPU או ממשקי API דומים. הדרך הכי קלה להבין את המבנה של מערכת הקואורדינטות היא לחשוב על הקשר שלה לקנבס בדף. לא משנה מה הרוחב או הגובה של אזור העריכה, הקצה השמאלי תמיד יהיה במיקום -1 בציר X, והקצה הימני תמיד יהיה במיקום +1 בציר X. באופן דומה, הקצה התחתון הוא תמיד -1 בציר Y, והקצה העליון הוא +1 בציר Y. כלומר, (0, 0) הוא תמיד מרכז הקנבס, (-1, -1) הוא תמיד הפינה השמאלית התחתונה ו-(1, 1) הוא תמיד הפינה הימנית העליונה. המרחב הזה נקרא מרחב חיתוך.

בדרך כלל הקודקודים לא מוגדרים במערכת הקואורדינטות הזו מלכתחילה, ולכן מעבדי GPU מסתמכים על תוכניות קטנות שנקראות vertex shaders כדי לבצע את החישובים שנדרשים להמרת הקודקודים למרחב החיתוך, וגם חישובים אחרים שנדרשים לציור הקודקודים. לדוגמה, יכול להיות שה-shader יחיל אנימציה מסוימת או יחשב את הכיוון מהקודקוד למקור אור. אתם, מפתחי WebGPU, כותבים את ה-shaders האלה, והם מספקים לכם שליטה רבה באופן הפעולה של ה-GPU.
משם, ה-GPU לוקח את כל המשולשים שנוצרו על ידי הקודקודים שעברו טרנספורמציה וקובע אילו פיקסלים במסך נדרשים כדי לצייר אותם. לאחר מכן, היא מריצה תוכנית קטנה נוספת שכתבתם, שנקראת fragment shader, שמחשבת את הצבע של כל פיקסל. החישוב יכול להיות פשוט כמו החזרת ירוק או מורכב כמו חישוב הזווית של פני השטח ביחס לאור השמש שמוחזר מפני שטח סמוכים אחרים, מסונן דרך ערפל ומשתנה בהתאם למידת המתכתיות של פני השטח. החישוב הזה נתון לשליטתכם המלאה, וזה יכול להיות גם מעצים וגם מבלבל.
התוצאות של צבעי הפיקסלים האלה מצטברות ויוצרות טקסטורה, שאפשר להציג במסך.
הגדרת קודקודים
כפי שצוין קודם, סימולציית משחק החיים מוצגת כרשת של תאים. באפליקציה צריך להיות אמצעי להצגת הרשת, כדי להבחין בין תאים פעילים לתאים לא פעילים. ב-codelab הזה נשתמש בגישה של ציור ריבועים צבעוניים בתאים פעילים והשארת תאים לא פעילים ריקים.
כלומר, תצטרכו לספק ל-GPU ארבע נקודות שונות, אחת לכל אחת מארבע הפינות של הריבוע. לדוגמה, אם מציירים ריבוע במרכז הקנבס, ומושכים אותו מהקצוות, הקואורדינטות של הפינות שלו ייראו כך:

כדי להעביר את הקואורדינטות האלה ל-GPU, צריך להציב את הערכים ב-TypedArray. אם אתם לא מכירים את TypedArrays, אלה קבוצה של אובייקטים ב-JavaScript שמאפשרים להקצות בלוקים רציפים של זיכרון ולפרש כל אלמנט בסדרה כסוג נתונים ספציפי. לדוגמה, ב-Uint8Array, כל רכיב במערך הוא בייט יחיד לא חתום. TypedArrays מצוינים לשליחת נתונים הלוך ושוב עם ממשקי API שרגישים לפריסת הזיכרון, כמו 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 של קודקוד אחד.
אבל יש בעיה! מעבדי GPU פועלים במונחים של משולשים, זוכרים? לכן, צריך לספק את הקודקודים בקבוצות של שלושה. יש לך קבוצה אחת של ארבעה. הפתרון הוא לחזור על שניים מהקודקודים כדי ליצור שני משולשים שחולקים צלע באמצע הריבוע.

כדי ליצור את הריבוע מהדיאגרמה, צריך לרשום את הקודקודים (-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,
]);
למרות שבתרשים יש הפרדה בין שני המשולשים לשם הבהרה, מיקומי הקודקודים זהים לחלוטין, וה-GPU מעבד אותם ללא רווחים. הוא יוצג כריבוע מוצק אחד.
יצירת מאגר זמני של ורטקסים
ה-GPU לא יכול לצייר קודקודים עם נתונים ממערך JavaScript. לרוב, למעבדי GPU יש זיכרון משלהם שעבר אופטימיזציה גבוהה לעיבוד תמונה, ולכן כל נתון שרוצים שמעבד ה-GPU ישתמש בו בזמן שהוא מצייר צריך להיות ממוקם בזיכרון הזה.
עבור הרבה ערכים, כולל נתוני קודקודים, הזיכרון בצד ה-GPU מנוהל באמצעות אובייקטים של GPUBuffer. מאגר הוא בלוק זיכרון שקל לגשת אליו במעבד ה-GPU, והוא מסומן למטרות מסוימות. אפשר לחשוב על זה קצת כמו על TypedArray שגלוי ל-GPU.
- כדי ליצור מאגר זמני לאחסון הקודקודים, מוסיפים את הקריאה הבאה אל
device.createBuffer()אחרי ההגדרה של מערךvertices.
index.html
const vertexBuffer = device.createBuffer({
label: "Cell vertices",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
הדבר הראשון שחשוב לשים לב אליו הוא שאתם נותנים למאגר תווית. לכל אובייקט WebGPU שיוצרים אפשר לתת תווית אופציונלית, וכדאי מאוד לעשות את זה. התווית יכולה להיות כל מחרוזת שרוצים, כל עוד היא עוזרת לזהות מהו האובייקט. אם נתקלים בבעיות, התוויות האלה משמשות בהודעות השגיאה ש-WebGPU יוצרת כדי לעזור להבין מה השתבש.
לאחר מכן, מציינים את הגודל של המאגר בבייטים. צריך מאגר נתונים זמני עם 48 בייטים, שנקבע על ידי הכפלת הגודל של מספר נקודה צפה (float) בן 32 ביט (4 בייטים) במספר המספרים הצפים במערך vertices (12). למזלנו, TypedArrays כבר מחשב את byteLength בשבילכם, כך שתוכלו להשתמש בו כשאתם יוצרים את ה-buffer.
לבסוף, צריך לציין את השימוש במאגר. זהו אחד או יותר מהדגלים GPUBufferUsage, כאשר כמה דגלים משולבים באמצעות האופרטור | ( bitwise OR). במקרה הזה, מציינים שרוצים להשתמש במאגר הנתונים הזמני לנתוני קודקוד (GPUBufferUsage.VERTEX) וגם רוצים להעתיק אליו נתונים (GPUBufferUsage.COPY_DST).
אובייקט המאגר שמוחזר אליכם הוא אטום – אי אפשר (בקלות) לבדוק את הנתונים שהוא מכיל. בנוסף, רוב המאפיינים שלו הם קבועים – אי אפשר לשנות את הגודל של GPUBuffer אחרי שהוא נוצר, וגם אי אפשר לשנות את דגלי השימוש. מה שכן אפשר לשנות זה את התוכן של הזיכרון שלו.
כשמאגר הנתונים נוצר, הזיכרון שהוא מכיל מאותחל לאפס. יש כמה דרכים לשנות את התוכן שלו, אבל הדרך הכי קלה היא לקרוא ל- device.queue.writeBuffer() עם TypedArray שרוצים להעתיק.
- כדי להעתיק את נתוני הקודקוד לזיכרון של המאגר, מוסיפים את הקוד הבא:
index.html
device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);
הגדרת פריסת הקודקודים
עכשיו יש לכם מאגר עם נתוני קודקודים, אבל מבחינת ה-GPU זה רק בלוב של בייטים. אם רוצים לצייר איתו משהו, צריך לספק עוד קצת מידע. צריך לספק ל-WebGPU מידע נוסף על המבנה של נתוני הקודקוד.
- מגדירים את מבנה נתוני הקודקוד באמצעות מילון
GPUVertexBufferLayout:
index.html
const vertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0, // Position, see vertex shader
}],
};
במבט ראשון זה יכול להיות קצת מבלבל, אבל יחסית קל להבין את זה.
הדבר הראשון שצריך לציין הוא arrayStride. זהו מספר הבייטים שה-GPU צריך לדלג קדימה בבאפר כשהוא מחפש את הקודקוד הבא. כל קודקוד של הריבוע מורכב משני מספרים נקודתיים בני 32 ביט. כמו שצוין קודם, מספר נקודה צפה (float) של 32 ביט הוא 4 בייט, ולכן שני מספרים כאלה הם 8 בייט.
אחריו מופיע המאפיין attributes, שהוא מערך. מאפיינים הם חלקי המידע הבודדים שמקודדים בכל קודקוד. הקוד שלכם מכיל רק מאפיין אחד (המיקום של הקודקוד), אבל בתרחישי שימוש מתקדמים יותר יש לעיתים קרובות קודקודים עם כמה מאפיינים, כמו הצבע של הקודקוד או הכיוון שאליו פונה המשטח הגיאומטרי. אבל זה לא נכלל ב-Codelab הזה.
במאפיין היחיד, קודם מגדירים את format של הנתונים. הנתונים האלה מגיעים מרשימה של סוגי GPUVertexFormat שמתארים כל סוג של נתוני קודקוד שה-GPU יכול להבין. לכל קודקוד יש שני ערכי float של 32 ביט, ולכן משתמשים בפורמט float32x2. אם נתוני הקודקוד מורכבים מארבעה מספרים שלמים לא שליליים בני 16 ביט כל אחד, למשל, צריך להשתמש ב-uint16x4 במקום זאת. רואה את התבנית?
לאחר מכן, offset מתאר כמה בייטים מתחילת הקודקוד מתחיל המאפיין הספציפי הזה. אתם צריכים לדאוג לגבי זה רק אם יש יותר מאפיין אחד במאגר הנתונים הזמני, וזה לא יקרה במהלך ה-Codelab הזה.
לבסוף, יש לכם את shaderLocation. זהו מספר שרירותי בין 0 ל-15, והוא צריך להיות ייחודי לכל מאפיין שאתם מגדירים. הוא מקשר את המאפיין הזה לקלט מסוים ב-vertex shader, שיוסבר בקטע הבא.
שימו לב שלמרות שהגדרתם את הערכים האלה עכשיו, אתם עדיין לא מעבירים אותם לשום מקום ב-WebGPU API. הסבר על כך יופיע בהמשך, אבל הכי קל לחשוב על הערכים האלה בשלב שבו מגדירים את הקודקודים, ולכן כדאי להגדיר אותם עכשיו כדי להשתמש בהם בהמשך.
איך מתחילים לעבוד עם shaders
עכשיו יש לכם את הנתונים שאתם רוצים לעבד, אבל אתם עדיין צריכים להגיד ל-GPU בדיוק איך לעבד אותם. חלק גדול מזה קורה באמצעות shaders.
Shaders הם תוכנות קטנות שאתם כותבים ומופעלות ב-GPU. כל Shader פועל בשלב אחר של הנתונים: עיבוד Vertex, עיבוד Fragment או חישוב כללי. מכיוון שהם נמצאים ב-GPU, המבנה שלהם קשיח יותר מ-JavaScript רגיל. אבל המבנה הזה מאפשר להם לבצע פעולות מהר מאוד, וחשוב מכך, במקביל!
ה-Shaders ב-WebGPU נכתבים בשפת הצללה שנקראת WGSL (WebGPU Shading Language). מבחינת תחביר, WGSL דומה קצת ל-Rust, עם תכונות שמטרתן להקל ולזרז עבודות נפוצות של GPU (כמו מתמטיקה של וקטורים ומטריצות). הסבר מלא על שפת ההצללה חורג מהיקף ה-codelab הזה, אבל אנחנו מקווים שתלמדו כמה מהיסודות כשנעבור על כמה דוגמאות פשוטות.
ה-shaders עצמם מועברים ל-WebGPU כמחרוזות.
- כדי ליצור מקום להזנת קוד ה-Shader, מעתיקים את הקוד הבא אל הקוד שמתחת ל-
vertexBufferLayout:
index.html
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
כדי ליצור את ה-shaders, קוראים ל-device.createShaderModule() ומספקים לו label ו-WGSL code אופציונליים כמחרוזת. (שימו לב שמשתמשים כאן בגרשיים הפוכים כדי לאפשר מחרוזות מרובות שורות!) אחרי שמוסיפים קוד WGSL תקין, הפונקציה מחזירה אובייקט GPUShaderModule עם התוצאות המהודרות.
הגדרת תוכנת הצללה של קודקודים
מתחילים עם הצללה של קודקודים כי שם מתחיל גם ה-GPU.
vertex shader מוגדר כפונקציה, וה-GPU קורא לפונקציה הזו פעם אחת לכל קודקוד ב-vertexBuffer. מכיוון שלצורה vertexBuffer יש שישה מיקומים (קודקודים), הפונקציה שהגדרתם נקראת שש פעמים. בכל פעם שהיא נקראת, מועבר לפונקציה מיקום אחר מ-vertexBuffer כארגומנט, והתפקיד של פונקציית הצללת הקודקודים הוא להחזיר מיקום תואם במרחב הקליפ.
חשוב להבין שהן לא בהכרח יופעלו בסדר עוקב. לעומת זאת, מעבדי GPU מצטיינים בהרצת הצללות כאלה במקביל, ויכולים לעבד מאות (או אפילו אלפים!) של קודקודים בו-זמנית. זהו חלק חשוב מאוד במהירות המדהימה של מעבדי GPU, אבל יש לזה מגבלות. כדי להבטיח מקסימום הקבלה, שיידרים של קודקודים לא יכולים לתקשר ביניהם. כל הפעלה של Shader יכולה לראות נתונים של קודקוד אחד בלבד בכל פעם, והיא יכולה להפיק ערכים רק לקודקוד אחד.
ב-WGSL, אפשר לתת לפונקציית vertex shader כל שם שרוצים, אבל צריך להוסיף לפניה את @vertex attribute כדי לציין את שלב ההצללה שהיא מייצגת. ב-WGSL, מציינים פונקציות באמצעות מילת המפתח fn, משתמשים בסוגריים כדי להצהיר על ארגומנטים, ובסוגריים מסולסלים כדי להגדיר את ההיקף.
- יוצרים פונקציה ריקה
@vertex, כמו בדוגמה הבאה:
index.html (createShaderModule code)
@vertex
fn vertexMain() {
}
עם זאת, זה לא תקין, כי shader של קודקוד חייב להחזיר לפחות את המיקום הסופי של הקודקוד שעובר עיבוד במרחב קליפ. הערך הזה תמיד ניתן כווקטור 4-ממדי. וקטורים הם דבר כל כך נפוץ לשימוש ב-shaders, שהם נחשבים לפרימיטיבים ברמה גבוהה בשפה, עם סוגים משלהם כמו vec4f לווקטור 4-ממדי. יש גם סוגים דומים לווקטורים דו-ממדיים (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), ויש לכם טכנית הצללת קודקודים תקינה, אבל כזו שלא מציגה כלום כי ה-GPU מזהה שהמשולשים שהיא יוצרת הם רק נקודה אחת ואז מבטל אותם.
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 נראה טבעי.
- משנים את פונקציית ה-shader לקוד הבא:
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);
}
עם זאת, מכיוון שמיפויים מהסוג הזה נפוצים מאוד ב-shaders, אפשר גם להעביר את וקטור המיקום כארגומנט הראשון בקיצור נוח, והמשמעות היא אותה משמעות.
- כותבים מחדש את ההצהרה
returnבאמצעות הקוד הבא:
index.html (קוד createShaderModule)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
זהו ה-vertex shader הראשוני שלכם. היא פשוטה מאוד, היא מעבירה את המיקום בלי לשנות אותו, אבל היא מספיק טובה כדי להתחיל.
הגדרת shader של מקטעים
השלב הבא הוא תוכנת ההצללה של המקטע. הפעלת הצללות של פרגמנטים דומה מאוד להפעלת הצללות של קודקודים, אבל במקום להפעיל אותן לכל קודקוד, הן מופעלות לכל פיקסל שמצויר.
הקריאה ל-fragment shaders תמיד מתבצעת אחרי הקריאה ל-vertex shaders. ה-GPU מקבל את הפלט של הצללות הקודקודים ומבצע טריאנגולציה, כלומר יוצר משולשים מקבוצות של שלוש נקודות. לאחר מכן, הוא מבצע רסטריזציה לכל אחד מהמשולשים האלה על ידי חישוב אילו פיקסלים של קבצים מצורפים של צבע הפלט כלולים במשולש, ואז קורא ל-fragment shader פעם אחת לכל אחד מהפיקסלים האלה. ה-fragment shader מחזיר צבע, שבדרך כלל מחושב מתוך ערכים שנשלחים אליו מה-vertex shader ומנכסים כמו טקסטורות, שה-GPU כותב ל-color attachment.
בדומה ל-vertex shaders, fragment shaders מופעלים באופן מקבילי. הן קצת יותר גמישות מ-vertex shaders מבחינת הקלט והפלט שלהן, אבל אפשר לחשוב עליהן כפונקציות שמחזירות צבע אחד לכל פיקסל של כל משולש.
פונקציית הצללה של קטע 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)
}
זהו, סיימתם ליצור את shader המקטעים. הוא לא מעניין במיוחד, הוא פשוט מגדיר את כל הפיקסלים של כל משולש לאדום, אבל זה מספיק לעכשיו.
לסיכום, אחרי שמוסיפים את קוד ה-Shader שמתואר למעלה, הקריאה createShaderModule נראית כך:
index.html
const cellShaderModule = device.createShaderModule({
label: 'Cell shader',
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
`
});
יצירת צינור עיבוד נתונים של רנדר
אי אפשר להשתמש במודול Shader לעיבוד בפני עצמו. במקום זאת, צריך להשתמש בו כחלק מ-GPURenderPipeline, שנוצר על ידי קריאה ל-device.createRenderPipeline(). צינור העיבוד קובע איך מצוירת גיאומטריה, כולל דברים כמו אילו הצללות משמשות, איך לפרש נתונים במאגרי קודקודים, איזה סוג של גיאומטריה צריך לעבד (קווים, נקודות, משולשים...) ועוד!
צינור הרינדור הוא האובייקט הכי מורכב בכל ה-API, אבל אל דאגה! רוב הערכים שאפשר להעביר אליו הם אופציונליים, וצריך לספק רק כמה ערכים כדי להתחיל.
- יוצרים צינור עיבוד תמונות (render pipeline) באופן הבא:
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" כרגע, וצינור עיבוד הנתונים יוצר פריסה משלו מה-shaders.
לאחר מכן, צריך לספק פרטים על שלב vertex. module הוא GPUShaderModule שמכיל את הצללת הקודקודים, ו-entryPoint הוא שם הפונקציה בקוד הצללה שמופעלת לכל קריאה של קודקוד. (אפשר להשתמש בכמה פונקציות @vertex ו-@fragment במודול הצללה אחד!) buffers הוא מערך של אובייקטים מסוג GPUVertexBufferLayout שמתארים איך הנתונים שלכם נארזים במאגרי הקודקודים שבהם אתם משתמשים בצינור הזה. למזלך, כבר הגדרת את זה קודם ב-vertexBufferLayout! כאן מעבירים את הנתונים.
לבסוף, יש פרטים על השלב fragment. הוא כולל גם מודול של Shader ו-entryPoint, כמו שלב הקודקוד. השלב האחרון הוא להגדיר את targets שבה נעשה שימוש בצינור הזה. זו מערך של מילונים עם פרטים – כמו המרקם format – של קבצים מצורפים של צבע שהצינור מוציא. הפרטים האלה צריכים להיות זהים לטקסטורות שמופיעות ב-colorAttachments של כל שלבי העיבוד שבהם נעשה שימוש בצינור הזה. ה-render pass משתמש בטקסטורות מהקונטקסט של ה-canvas, ומשתמש בערך ששמרתם ב-canvasFormat בשביל הפורמט שלו, לכן אתם מעבירים כאן את אותו פורמט.
אלה רק חלק מהאפשרויות שאפשר לציין כשיוצרים צינור עיבוד, אבל הן מספיקות לצרכים של ה-codelab הזה.
שרטוט הריבוע
ועכשיו יש לכם את כל מה שצריך כדי לצייר את הריבוע.
- כדי לצייר את הריבוע, חוזרים אל צמד השיחות
encoder.beginRenderPass()ו-pass.end(), ומוסיפים ביניהן את הפקודות החדשות האלה:
index.html
// After encoder.beginRenderPass()
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices
// before pass.end()
כך מספקים ל-WebGPU את כל המידע שנדרש כדי לצייר את הריבוע. קודם כול, משתמשים ב- setPipeline() כדי לציין באיזה צינור צריך להשתמש כדי לצייר. הנתונים האלה כוללים את הצללות שבהן נעשה שימוש, הפריסה של נתוני הקודקודים ונתוני מצב רלוונטיים אחרים.
בשלב הבא, מתקשרים אל setVertexBuffer() עם המאגר שמכיל את הקודקודים של הריבוע. קוראים לו 0 כי המאגר הזה תואם לאלמנט מספר 0 בהגדרה של vertex.buffers בצינור הנוכחי.
ולבסוף, מתבצעת השיחה draw(), שנראית פשוטה באופן מוזר אחרי כל ההגדרה שקדמה לה. הדבר היחיד שצריך להעביר הוא מספר הקודקודים שצריך לעבד, והוא נשלף ממאגרי הקודקודים שהוגדרו כרגע ומפורש באמצעות צינור העיבוד שהוגדר כרגע. אפשר פשוט להגדיר את הערך כ-6, אבל אם מחשבים אותו ממערך הקודקודים (12 מספרים ממשיים חלקי 2 קואורדינטות לכל קודקוד שווה ל-6 קודקודים), זה אומר שאם תחליטו להחליף את הריבוע, למשל בעיגול, יהיה פחות מה לעדכן באופן ידני.
- מרעננים את המסך ורואים (סוף סוף) את התוצאות של כל העבודה הקשה: ריבוע גדול וצבעוני.

5. ציור רשת
קודם כול, כדאי לברך את עצמכם! אחד השלבים הקשים ביותר ברוב ממשקי ה-API של GPU הוא קבלת הביטים הראשונים של הגיאומטריה במסך. כל מה שאתם עושים מכאן אפשר לעשות בשלבים קטנים יותר, וכך קל יותר לאמת את ההתקדמות תוך כדי.
בקטע הזה מוסבר:
- איך מעבירים משתנים (שנקראים אחידים) לתוכנת הצללה (shader) מ-JavaScript.
- איך משתמשים במשתנים אחידים כדי לשנות את התנהגות העיבוד.
- איך משתמשים במופע כדי לשרטט הרבה גרסאות שונות של אותה צורה גיאומטרית.
הגדרת הרשת
כדי לעבד רשת, צריך לדעת פרט בסיסי מאוד לגביה. כמה תאים יש בו, גם ברוחב וגם בגובה? ההחלטה הזו נתונה לשיקול דעתכם כמפתחים, אבל כדי להקל על העניינים, כדאי להתייחס לרשת כאל ריבוע (אותו רוחב ואותו גובה) ולהשתמש בגודל שהוא חזקה של 2. (כך יהיה קל יותר לבצע את החישובים בהמשך). אתם רוצים להגדיל אותו בסופו של דבר, אבל בשאר הקטע הזה, הגדירו את גודל הרשת ל-4x4 כי כך קל יותר להדגים חלק מהחישובים שמשמשים בקטע הזה. אפשר להגדיל את המינון בהמשך.
- מגדירים את גודל הרשת על ידי הוספת קבוע לחלק העליון של קוד ה-JavaScript.
index.html
const GRID_SIZE = 4;
בשלב הבא, צריך לעדכן את אופן העיבוד של הריבוע כדי שיהיה אפשר להציג GRID_SIZE ריבועים בגודל GRID_SIZE על אזור הציור. כלומר, הריבוע צריך להיות הרבה יותר קטן, וצריך הרבה ריבועים.
אחת הדרכים לעשות את זה היא להגדיל משמעותית את מאגר הנתונים הזמני של הקודקודים ולהגדיר בתוכו GRID_SIZE פעמים GRID_SIZE ריבועים בגודל ובמיקום הנכונים. הקוד של זה לא יהיה כל כך נורא, למעשה! רק כמה לולאות for וקצת מתמטיקה. אבל גם במקרה הזה לא נעשה שימוש אופטימלי במעבד הגרפי, והמערכת משתמשת ביותר זיכרון מהנדרש כדי להשיג את האפקט. בקטע הזה נבחן גישה שמתאימה יותר ל-GPU.
יצירת מאגר אחיד
קודם כל, צריך להעביר את גודל הרשת שבחרתם לתוכנת ההצללה (shader), כי היא משתמשת בזה כדי לשנות את אופן התצוגה של הדברים. אפשר פשוט להגדיר את הגודל בקוד של ה-Shader, אבל אז בכל פעם שרוצים לשנות את גודל הרשת צריך ליצור מחדש את ה-Shader ואת צינור הרינדור, וזה תהליך יקר. דרך טובה יותר היא לספק את גודל הרשת לשיידר כמשתנים אחידים.
כמו שלמדתם קודם, ערך שונה ממאגר נתונים זמני של קודקודי התלת-ממד מועבר לכל הפעלה של תוכנת הצללה (shader) של קודקודים. ערך אחיד הוא ערך ממאגר נתונים זמני שזהה לכל הפעלה. הם שימושיים להעברת ערכים שמשותפים לחלק גיאומטרי (כמו המיקום שלו), למסגרת מלאה של אנימציה (כמו השעה הנוכחית) או אפילו למשך החיים של האפליקציה (כמו העדפת משתמש).
- כדי ליצור מאגר אחיד, מוסיפים את הקוד הבא:
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.
גישה למשתנים אחידים בתוכנת הצללה (shader)
- מוסיפים את הקוד הבא כדי להגדיר uniform:
index.html (קריאה ל-createShaderModule)
// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos / grid, 0, 1);
}
// ...fragmentMain is unchanged
הפעולה הזו מגדירה אחידות בתוכנת הצללה (shader) שנקרא grid, שהוא וקטור של מספרים ממשיים דו-ממדיים שתואם למערך שהעתקתם זה עתה למאגר האחיד. בנוסף, מצוין שהמדים קשורים ל@group(0) ול@binding(0). בהמשך נסביר מה המשמעות של הערכים האלה.
לאחר מכן, במקום אחר בקוד של ה-Shader, אפשר להשתמש בווקטור של הרשת בכל דרך שרוצים. בקוד הזה מחלקים את מיקום הקודקוד בווקטור הרשת. pos הוא וקטור דו-ממדי ו-grid הוא וקטור דו-ממדי, לכן WGSL מבצעת חלוקה לפי רכיבים. במילים אחרות, התוצאה זהה לתוצאה של vec2f(pos.x / grid.x, pos.y / grid.y).
פעולות וקטוריות מהסוג הזה נפוצות מאוד ב-shaders של GPU, כי הרבה טכניקות של עיבוד וחישוב מסתמכות עליהן.
במקרה שלכם, אם השתמשתם בגודל רשת של 4, הריבוע שיוצג יהיה בגודל של רבע מהגודל המקורי שלו. זה מצוין אם רוצים להציב ארבעה מהם בשורה או בעמודה.
יצירת קבוצת קישור
אבל הצהרה על המשתנה האחיד ב-shader לא מקשרת אותו למאגר שיצרתם. כדי לעשות את זה, צריך ליצור ולהגדיר קבוצת קישור.
קבוצת קשירה היא אוסף של משאבים שרוצים להפוך לנגישים ל-shader בו-זמנית. הוא יכול לכלול כמה סוגים של מאגרי נתונים זמניים, כמו מאגר נתונים זמני אחיד, ומשאבים אחרים כמו טקסטורות ודגימות שלא מוסברים כאן, אבל הם חלקים נפוצים בטכניקות רינדור של 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". כתוצאה מכך, צינור העיבוד יוצר פריסות של קבוצות קשירה באופן אוטומטי מהקשירות שהצהרתם עליהן בקוד של ה-shader עצמו. במקרה כזה, מבקשים ממנו getBindGroupLayout(0), כאשר 0 מתאים ל-@group(0) שהקלדתם בתוכנת ההצללה.
אחרי שמציינים את הפריסה, מספקים מערך של entries. כל רשומה היא מילון עם הערכים הבאים לפחות:
-
binding, שמתאים לערך@binding()שהזנתם ב-Shader. במקרה הזה,0. -
resource, שהוא המשאב בפועל שרוצים לחשוף למשתנה באינדקס הקישור שצוין. במקרה הזה, מאגר אחיד.
הפונקציה מחזירה GPUBindGroup, שהוא אובייקט אטום ובלתי ניתן לשינוי. אחרי שיוצרים קבוצת קשירה, אי אפשר לשנות את המשאבים שהיא מפנה אליהם, אבל אפשר לשנות את התוכן של המשאבים האלה. לדוגמה, אם משנים את מאגר הנתונים האחיד כך שיכיל גודל רשת חדש, השינוי ישתקף בקריאות עתידיות לציור באמצעות קבוצת ההתאמה הזו.
קישור קבוצת הקישור
אחרי שיוצרים את קבוצת הקישור, עדיין צריך להגדיר ב-WebGPU שימוש בקבוצה הזו בזמן הציור. למזלנו, זה די פשוט.
- חוזרים אל render pass ומוסיפים את השורה החדשה הזו לפני השיטה
draw():
index.html
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup); // New line!
pass.draw(vertices.length / 2);
הפרמטר 0 שמועבר כארגומנט הראשון תואם ל-@group(0) בקוד של ה-shader. המשמעות היא שכל @binding שהוא חלק מ-@group(0) משתמש במשאבים בקבוצת ההתאמה הזו.
ועכשיו מאגר הנתונים האחיד חשוף לתוכנת ההצללה (shader) שלך!
- מרעננים את הדף, ואז אמור להופיע משהו כזה:

מעולה! הריבוע שלך הוא עכשיו רבע מהגודל שהיה קודם! זה לא הרבה, אבל זה מראה שהמדים מוחלים בפועל ושה-shader יכול עכשיו לגשת לגודל הרשת.
שינוי גיאומטריה ב-Shader
עכשיו, כשאתם יכולים להפנות לגודל הרשת ב-Shader, אתם יכולים להתחיל לעבוד על שינוי הגיאומטריה שאתם מציגים כדי להתאים אותה לתבנית הרשת הרצויה. כדי לעשות את זה, חשבו בדיוק מה אתם רוצים להשיג.
צריך לחלק את אזור העריכה לתאים נפרדים. כדי לשמור על המוסכמה שלפיה ציר X גדל כשמתקדמים ימינה וציר Y גדל כשמתקדמים למעלה, נניח שהתא הראשון נמצא בפינה השמאלית התחתונה של אזור הציור. כך תקבלו פריסה שנראית כך, עם הגיאומטריה הריבועית הנוכחית באמצע:

האתגר שלכם הוא למצוא שיטה בתוכנת הצללה (shader) שתאפשר לכם למקם את הגיאומטריה של הריבוע בכל אחד מהתאים האלה, בהינתן הקואורדינטות של התא.
קודם כול, אפשר לראות שהריבוע לא מיושר בצורה טובה עם אף אחת מהתאים, כי הוא הוגדר להקיף את מרכז בד הציור. כדי שהריבוע יתאים בדיוק בתוך התאים, צריך להזיז אותו בחצי תא.
אחת הדרכים לפתור את הבעיה היא לעדכן את מאגר הקודקודים של הריבוע. אם משנים את הקודקודים כך שהפינה השמאלית התחתונה תהיה בנקודה (0.1, 0.1) במקום בנקודה (-0.8, -0.8), הריבוע יזוז ויתיישר בצורה טובה יותר עם גבולות התא. אבל מכיוון שיש לכם שליטה מלאה באופן העיבוד של הקודקודים בתוכנת ההצללה (shader), אפשר פשוט להזיז אותם למקום באמצעות קוד תוכנת ההצללה (shader).
- משנים את מודול הצללת הקודקוד באמצעות הקוד הבא:
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);
}
כל קודקוד זז למעלה ולימין ב-1 (שזה, כזכור, חצי ממרחב החיתוך) לפני שמחלקים אותו בגודל הרשת. התוצאה היא ריבוע שמוצב בצורה יפה על הרשת, קצת רחוק מנקודת המקור.

בשלב הבא, מכיוון שבמערכת הקואורדינטות של אזור הציור (canvas) הערך (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+, ולכן הרוחב הוא בעצם 2 יחידות. כלומר, אם רוצים להזיז קודקוד לרבע מהרוחב של אזור הציור, צריך להזיז אותו ב-0.5 יחידות. זו טעות שקל לעשות כשמנתחים נתונים עם קואורדינטות של GPU. למזלכם, התיקון פשוט באותה מידה.
- מכפילים את ההיסט ב-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 פעם אחת לכל ריבוע ברשת, ולעדכן את המאגר נתונים זמני האחיד בכל פעם. אבל זה יהיה מאוד איטי, כי ה-GPU יצטרך לחכות בכל פעם עד ש-JavaScript יכתוב את הקואורדינטה החדשה. אחד מהגורמים העיקריים לשיפור הביצועים של ה-GPU הוא צמצום הזמן שהוא מבלה בהמתנה לחלקים אחרים במערכת.
במקום זאת, אפשר להשתמש בטכניקה שנקראת יצירת מופעים. העתקה היא דרך להורות ל-GPU לצייר כמה עותקים של אותה גיאומטריה באמצעות קריאה אחת ל-draw, וזה הרבה יותר מהיר מאשר קריאה ל-draw פעם אחת לכל עותק. כל עותק של הגיאומטריה נקרא מופע.
- כדי להגיד ל-GPU שאתם רוצים מספיק מופעים של הריבוע כדי למלא את הרשת, מוסיפים ארגומנט אחד לבקשה הקיימת להזזת פריט גרפי (draw call):
index.html
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
הפקודה הזו אומרת למערכת שרוצים שהיא תצייר את ששת (vertices.length / 2) הקודקודים של הריבוע 16 (GRID_SIZE * GRID_SIZE) פעמים. אבל אם מרעננים את הדף, עדיין רואים את ההודעה הבאה:

למה? הסיבה לכך היא שציירת את כל 16 הריבועים האלה באותו מקום. צריך להוסיף ל-Shader לוגיקה נוספת שתשנה את המיקום של הגיאומטריה על בסיס כל מופע בנפרד.
ב-shader, בנוסף למאפייני הקודקוד כמו pos שמגיעים ממאגר הקודקודים, אפשר גם לגשת למה שנקרא ערכים מובנים של WGSL. אלה ערכים שמחושבים על ידי WebGPU, ואחד מהם הוא instance_index. instance_index הוא מספר לא חתום בן 32 ביט מ-0 עד number of instances - 1 שאפשר להשתמש בו כחלק מהלוגיקה של Shader. הערך שלו זהה לכל קודקוד שעובר עיבוד והוא חלק מאותו מופע. כלומר, הפונקציה vertex shader נקראת שש פעמים עם ערך instance_index של 0, פעם אחת לכל מיקום במאגר הקודקודים. אחר כך עוד 6 פעמים עם instance_index של 1, אחר כך עוד 6 פעמים עם instance_index של 2 וכן הלאה.
כדי לראות את הפעולה הזו, צריך להוסיף את instance_index המובנה לקלט של הצללה. הפעולה הזו מתבצעת באותו אופן כמו הגדרת המיקום, אבל במקום לתייג אותה באמצעות מאפיין @location, משתמשים במאפיין @builtin(instance_index), ואז נותנים לארגומנט שם כלשהו. (אפשר לקרוא לו instance כדי שיתאים לקוד לדוגמה). ואז משתמשים בו כחלק מהלוגיקה של ה-Shader.
- משתמשים ב-
instanceבמקום בקואורדינטות של התא:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance); // Save the instance_index as a float
let cell = vec2f(i, i); // Updated
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
אם תרעננו עכשיו, תראו שיש לכם יותר מריבוע אחד. אבל לא תוכלו לראות את כל 16 התמונות.

הסיבה לכך היא שהקואורדינטות של התאים שאתם יוצרים הן (0, 0), (1, 1), (2, 2)... עד (15, 15), אבל רק ארבע מהן מתאימות לקנבס. כדי ליצור את הרשת הרצויה, צריך להמיר את instance_index כך שכל אינדקס ימופה לתא ייחודי ברשת, כמו בדוגמה הבאה:

החישוב של זה פשוט יחסית. לכל ערך X בתא, רוצים את המודולו של instance_index ורוחב הרשת, שאפשר לבצע ב-WGSL באמצעות האופרטור %. ולגבי כל ערך Y של תא, מחלקים את instance_index ברוחב הרשת ומתעלמים משארית השבר. אפשר לעשות את זה באמצעות הפונקציה floor() של WGSL.
- משנים את החישובים, כך:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
// Compute the cell coordinate from the instance_index
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
אחרי שמבצעים את העדכון הזה בקוד, סוף סוף מקבלים את רשת הריבועים המיוחלת!

- עכשיו שהיא פועלת, אפשר לחזור ולהגדיל את גודל הרשת.
index.html
const GRID_SIZE = 32;

טדה! עכשיו אפשר להגדיל את הרשת הזו מאוד, והמעבד הגרפי הממוצע יתמודד איתה בלי בעיה. הריבועים הנפרדים ייעלמו הרבה לפני שתיתקלו בצווארי בקבוק בביצועים של ה-GPU.
6. נקודות בונוס: תן לזה מראה צבעוני יותר!
בשלב הזה, אתם יכולים לדלג בקלות לקטע הבא, כי הנחתם את הבסיס לכל שאר ה-Codelab. אבל למרות שהרשת של הריבועים שכולם באותו צבע היא שימושית, היא לא בדיוק מרגשת, נכון? למזלכם, אפשר להבהיר את התמונה בעזרת קצת יותר מתמטיקה וקוד הצללה.
שימוש במבני נתונים (structs) ב-shaders
עד עכשיו, העברתם נתון אחד מ-vertex shader: המיקום שעבר טרנספורמציה. אבל למעשה אפשר להחזיר הרבה יותר נתונים מה-vertex shader ואז להשתמש בהם ב-fragment shader.
הדרך היחידה להעביר נתונים מחוץ ל-vertex shader היא להחזיר אותם. תמיד צריך להחזיר מיקום באמצעות Vertex Shader, ולכן אם רוצים להחזיר נתונים אחרים יחד עם המיקום, צריך להציב אותם במבנה. מבנים ב-WGSL הם סוגי אובייקטים עם שמות שמכילים מאפיין אחד או יותר עם שמות. אפשר גם להוסיף למאפיינים מאפיינים כמו @builtin ו-@location. מגדירים אותם מחוץ לפונקציות, ואז אפשר להעביר מופעים שלהם אל תוך הפונקציות ומחוצה להן, לפי הצורך. לדוגמה, נניח שיש לכם את vertex shader הבא:
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);
}
- מבטאים את אותו הדבר באמצעות מבני נתונים (structs) עבור הקלט והפלט של הפונקציה:
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, וצריך להצהיר על המבנה שמוחזר כמשתנה ולהגדיר את המאפיינים הנפרדים שלו. במקרה הזה, אין הבדל משמעותי, ולמעשה הפונקציה של ה-shader קצת יותר ארוכה, אבל ככל שה-shaders נעשים מורכבים יותר, השימוש במבנים יכול להיות דרך מצוינת לארגן את הנתונים.
העברת נתונים בין פונקציות קודקוד ופונקציות מקטע
להזכירך, הפונקציה @fragment פשוטה ככל האפשר:
index.html (קריאה ל-createShaderModule)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
אתם לא מקבלים קלט, ומעבירים צבע אחיד (אדום) כפלט. אבל אם ה-shader ידע יותר על הגיאומטריה שהוא צובע, אפשר להשתמש בנתונים הנוספים האלה כדי להפוך את הדברים למעניינים יותר. לדוגמה, מה אם רוצים לשנות את הצבע של כל ריבוע על סמך קואורדינטת התא שלו? בשלב @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, מקבלים את הערך על ידי הוספת ארגומנט עם אותו@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);
}
- אפשרות נוספת, מכיוון ששתי הפונקציות האלה מוגדרות באותו מודול של Shader בקוד, היא לעשות שימוש חוזר במבנה הפלט של שלב
@vertex. כך קל להעביר ערכים כי השמות והמיקומים עקביים באופן טבעי.
index.html (קריאה ל-createShaderModule)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
לא משנה באיזה דפוס תבחרו, התוצאה היא שתהיה לכם גישה למספר התא בפונקציה @fragment ותוכלו להשתמש בו כדי להשפיע על הצבע. הפלט של כל אחד מהקודים שלמעלה נראה כך:

יש עכשיו יותר צבעים, אבל המראה לא ממש נעים. יכול להיות שתהיתם למה רק השורות השמאלית והתחתונה שונות. הסיבה לכך היא שערכי הצבע שמוחזרים מהפונקציה @fragment מצפים שכל ערוץ יהיה בטווח 0 עד 1, וכל ערך מחוץ לטווח הזה מוגבל לטווח הזה. לעומת זאת, הערכים בתאים נעים בין 0 ל-32 לאורך כל ציר. אז מה שרואים כאן זה שהשורה והעמודה הראשונות מגיעות מיד לערך המלא 1 בערוץ הצבע האדום או הירוק, וכל תא אחרי זה מוגבל לאותו ערך.
אם רוצים מעבר חלק יותר בין הצבעים, צריך להחזיר ערך חלקי לכל ערוץ צבעים, רצוי להתחיל מאפס ולסיים באחד לאורך כל ציר, מה שאומר שצריך לחלק שוב ב-grid.
- משנים את ה-fragment shader, כך:
index.html (קריאה ל-createShaderModule)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell/grid, 0, 1);
}
אם תרעננו את הדף, תוכלו לראות שהקוד החדש כן יוצר מעבר צבעים נעים יותר בכל הרשת.

זה בהחלט שיפור, אבל עכשיו יש פינה שחורה לא נעימה בצד שמאל למטה, שבה הרשת הופכת לשחורה. כשמתחילים להריץ את הסימולציה של משחק החיים, חלק מהרשת שקשה לראות מסתיר את מה שקורה. הייתי רוצה להגביר את הבהירות.
למזלכם, יש לכם ערוץ צבעים שלם שלא נמצא בשימוש – כחול – שבו אתם יכולים להשתמש. האפקט הרצוי הוא שהצבע הכחול יהיה הבהיר ביותר במקומות שבהם הצבעים האחרים הם הכהים ביותר, ואז ידהה ככל שהצבעים האחרים יהפכו לבהירים יותר. הדרך הכי קלה לעשות את זה היא להתחיל את הערוץ ב-1 ולהפחית אחד מהערכים בתא. הערך יכול להיות c.x או c.y. אפשר לנסות את שניהם ולבחור את זה שמתאים לכם יותר.
- מוסיפים צבעים בהירים יותר ל-fragment shader, כמו בדוגמה הבאה:
הפעלת createShaderModule
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let c = input.cell / grid;
return vec4f(c, 1-c.x, 1);
}
התוצאה נראית די טוב!

זה לא שלב קריטי! אבל מכיוון שהיא נראית טוב יותר, היא נכללת בקובץ המקור של נקודת הבדיקה המתאימה, ושאר צילומי המסך ב-codelab הזה משקפים את הרשת הצבעונית הזו.
7. ניהול מצב התא
לאחר מכן, צריך לקבוע אילו תאים ברשת יוצגו, על סמך מצב מסוים שמאוחסן ב-GPU. זה חשוב לסימולציה הסופית!
כל מה שצריך הוא אות הפעלה או השבתה לכל תא, כך שכל אפשרות שמאפשרת לכם לאחסן מערך גדול של כמעט כל סוג ערך תתאים. יכול להיות שתחשבו שזה עוד תרחיש לדוגמה לשימוש במאגרי מידע אחידים. אפשר לעשות את זה, אבל זה יותר מסובך כי המאגרים האחידים מוגבלים בגודל, לא תומכים במערכים בגודל דינמי (צריך לציין את גודל המערך ב-Shader) ולא ניתן לכתוב להם באמצעות Shader של מחשוב. הפריט האחרון הוא הבעייתי ביותר, כי רוצים לבצע את הסימולציה של Game of Life ב-GPU באמצעות shader לחישוב.
למזלנו, יש עוד אפשרות להגדרת מאגר זמני שמאפשרת לעקוף את כל המגבלות האלה.
יצירת מאגר אחסון
מאגרי אחסון הם מאגרים לשימוש כללי שאפשר לקרוא ולכתוב בהם ב-compute shaders, ולקרוא בהם ב-vertex shaders. הם יכולים להיות גדולים מאוד, ולא צריך להגדיר להם גודל ספציפי בתוכנת הצללה, ולכן הם דומים יותר לזיכרון כללי. זה מה שמשמש לאחסון מצב התא.
- כדי ליצור מאגר אחסון למצב התא, משתמשים בקטע קוד ליצירת מאגר, שסביר להניח שהוא כבר מוכר לכם:
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);
קריאת מאגר האחסון ב-Shader
לאחר מכן, מעדכנים את ה-shader כדי לבדוק את התוכן של מאגר האחסון לפני שמבצעים עיבוד של הרשת. התהליך הזה דומה מאוד לתהליך הקודם להוספת מדים.
- מעדכנים את ה-shader עם הקוד הבא:
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 גורמת לגיאומטריה לקרוס לנקודה אחת, ואז ה-GPU מבטל אותה.
- מעדכנים את קוד ה-shader כדי לשנות את קנה המידה של המיקום בהתאם למצב הפעיל של התא. כדי לעמוד בדרישות של WGSL בנוגע למניעת שגיאות הקלדה, צריך להמיר את ערך המצב ל-
f32:
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() המקביל בתוכנת ההצללה (shader).
אחרי שמגדירים את זה, אפשר לרענן ולראות את התבנית ברשת.

שימוש בשיטת מאגר הפינג-פונג
ברוב הסימולציות, כמו זו שאתם יוצרים, נעשה שימוש בשני עותקים לפחות של המצב. בכל שלב של הסימולציה, הם קוראים מעותק אחד של המצב וכותבים לעותק השני. בשלב הבא, הופכים אותו וקוראים מהמצב שאליו כתבו קודם. הדפוס הזה נקרא בדרך כלל דפוס של פינג פונג, כי בכל שלב הגרסה העדכנית ביותר של המצב עוברת הלוך ושוב בין עותקים של המצב.
למה זה נחוץ? הנה דוגמה פשוטה: נניח שאתם כותבים סימולציה פשוטה מאוד שבה אתם מזיזים כל בלוק פעיל ימינה בתא אחד בכל שלב. כדי שההבנה תהיה קלה, מגדירים את הנתונים והסימולציה ב-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() כדי לתזמן קריאות חוזרות (callback) באותו קצב שבו המסך מתרענן (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);
עכשיו, כשמריצים את האפליקציה, רואים שהבד מתהפך הלוך ושוב בין שני מאגרי המידע של המצב שיצרתם.

בשלב הזה, סיימתם את רוב העבודה שקשורה לרינדור. הכול מוכן להצגת הפלט של סימולציית משחק החיים שתיצרו בשלב הבא, שבו סוף סוף תתחילו להשתמש ב-compute shaders.
ברור שיש עוד הרבה יכולות רינדור ב-WebGPU מעבר לחלק הקטן שבדקתם כאן, אבל השאר לא נכלל במסגרת ה-codelab הזה. אני מקווה שההסבר הזה נותן לכם טעימה מספקת של אופן הפעולה של הרינדור ב-WebGPU, כך שיהיה לכם קל יותר להבין טכניקות מתקדמות יותר כמו רינדור תלת-ממדי.
8. הרצת הסימולציה
עכשיו, נותר לנו החלק האחרון והחשוב בפאזל: ביצוע סימולציית Game of Life באמצעות shader לחישוב.
סוף סוף אפשר להשתמש ב-compute shaders!
במהלך ה-Codelab הזה למדתם באופן כללי על shaders של מחשוב, אבל מה הם בדיוק?
Compute shader דומה ל-vertex ול-fragment shaders בכך שהוא מיועד להרצה עם מקביליות קיצונית ב-GPU, אבל בניגוד לשני שלבי ה-shader האחרים, אין לו קבוצה ספציפית של קלט ופלט. אתם קוראים וכותבים נתונים באופן בלעדי ממקורות שאתם בוחרים, כמו מאגרי נתונים זמניים. כלומר, במקום להריץ את הפונקציה פעם אחת לכל קודקוד, מופע או פיקסל, צריך לציין כמה קריאות לפונקציית ה-shader רוצים. לאחר מכן, כשמריצים את ה-shader, מקבלים מידע על הקריאה לפונקציה שעוברת עיבוד, ואפשר להחליט לאילו נתונים לגשת ואילו פעולות לבצע.
צריך ליצור את ה-Compute shaders במודול shader, בדיוק כמו את ה-vertex shaders ואת ה-fragment shaders, אז כדאי להוסיף את זה לקוד כדי להתחיל. כפי שאפשר לנחש, בהתחשב במבנה של שאר ה-shaders שהטמעתם, צריך לסמן את הפונקציה הראשית של ה-compute shader באמצעות המאפיין @compute.
- יוצרים shader לחישוב עם הקוד הבא:
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() {
}`
});
מכיוון שמעבדי GPU משמשים לעיתים קרובות לגרפיקה תלת-ממדית, שיידרים של מחשוב מובנים כך שאפשר לבקש שהשיידר יופעל מספר מסוים של פעמים לאורך ציר X, ציר Y וציר Z. כך תוכלו להקצות בקלות רבה עבודה שתואמת לרשת דו-ממדית או תלת-ממדית, וזה מצוין לתרחיש השימוש שלכם. רוצים להפעיל את ה-Shader הזה GRID_SIZE פעמים, פעם אחת לכל תא בסימולציה.GRID_SIZE
בגלל האופי של ארכיטקטורת חומרת ה-GPU, הרשת הזו מחולקת לקבוצות עבודה. לקבוצת עבודה יש גודל X, Y ו-Z, ולמרות שהגודל יכול להיות 1 בכל אחד מהמקרים, לרוב יש יתרונות בביצועים אם קבוצות העבודה גדולות יותר. עבור ה-shader, בוחרים גודל קבוצת עבודה שרירותי כלשהו של 8 כפול 8. כדאי לעקוב אחרי זה בקוד JavaScript.
- מגדירים קבוע לגודל קבוצת העבודה, כך:
index.html
const WORKGROUP_SIZE = 8;
צריך גם להוסיף את הגודל של קבוצת העבודה לפונקציית ה-shader עצמה, באמצעות תבניות מילוליות של JavaScript, כדי שתוכלו להשתמש בקבוע שהגדרתם בקלות.
- מוסיפים את גודל קבוצת העבודה לפונקציית ה-shader, כך:
index.html (קריאה ל-Compute createShaderModule)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
ההצהרה הזו אומרת ל-shader שהעבודה שמתבצעת באמצעות הפונקציה הזו מתבצעת בקבוצות של (8 x 8 x 1). (כל ציר שלא מציינים מקבל כברירת מחדל את הערך 1, אבל צריך לציין לפחות את ציר X).
בדומה לשלבי הצללה אחרים, יש מגוון של @builtin ערכים שאפשר לקבל כקלט לפונקציית הצללה של מחשוב כדי לדעת באיזה הפעלה אתם נמצאים ולהחליט איזו עבודה צריך לבצע.
- מוסיפים ערך
@builtin, כך:
index.html (קריאה ל-Compute createShaderModule)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
מעבירים את הפונקציה המובנית global_invocation_id, שהיא וקטור תלת-ממדי של מספרים שלמים לא מסומנים, שמציין את המיקום ברשת של קריאות הפונקציה של Shader. מריצים את ה-shader הזה פעם אחת לכל תא ברשת. מקבלים מספרים כמו (0, 0, 0), (1, 0, 0), (1, 1, 0)... עד (31, 31, 0), כלומר אפשר להתייחס לזה כאל אינדקס התא שבו רוצים לבצע פעולה.
בנוסף, אפשר להשתמש ב-uniforms ב-compute shaders, בדיוק כמו ב-vertex וב-fragment shaders.
- משתמשים ב-uniform עם shader של מחשוב כדי לציין את גודל הרשת, כך:
index.html (קריאה ל-Compute createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f; // New line
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
בדומה ל-vertex shader, גם כאן חושפים את מצב התא כמאגר אחסון. אבל במקרה הזה, צריך שניים! מכיוון של-compute shaders אין פלט נדרש, כמו מיקום קודקוד או צבע פרגמנט, הדרך היחידה לקבל תוצאות מ-compute shader היא לכתוב ערכים למאגר אחסון או לטקסטורה. משתמשים בשיטת הפינג-פונג שלמדתם קודם. יש מאגר אחסון אחד שמזין את המצב הנוכחי של הרשת, ומאגר אחד שכותבים אליו את המצב החדש של הרשת.
- כך חושפים את מצב הקלט והפלט של התא כמאגרי אחסון:
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>. כך תוכלו לקרוא ולכתוב למאגר נתונים זמני, ולהשתמש במאגר נתונים זמני הזה כפלט של תוכנת הצללה (shader) החישוב. (אין מצב אחסון לקריאה בלבד ב-WebGPU).
לאחר מכן, צריך למצוא דרך למפות את אינדקס התא למערך האחסון הלינארי. זה בעצם ההפך ממה שעשיתם ב-vertex shader, שבו לקחתם את 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) {
}
ולסיום, כדי לוודא שהיא פועלת, מטמיעים אלגוריתם פשוט מאוד: אם תא מסוים פועל כרגע, הוא יכבה, ולהפך. זה לא משחק החיים עדיין, אבל זה מספיק כדי להראות ש-compute shader פועל.
- מוסיפים את האלגוריתם הפשוט, כך:
index.html (קריאה ל-Compute createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines. Flip the cell state every step.
if (cellStateIn[cellIndex(cell.xy)] == 1) {
cellStateOut[cellIndex(cell.xy)] = 0;
} else {
cellStateOut[cellIndex(cell.xy)] = 1;
}
}
זהו, סיימתם לבינתיים עם shader החישוב. אבל לפני שתוכלו לראות את התוצאות, יש עוד כמה שינויים שצריך לבצע.
שימוש בפריסות של קבוצות כבילה וצינורות עיבוד נתונים
דבר אחד שאפשר לשים לב אליו בתוכנת ההצללה (shader) שלמעלה הוא שהיא משתמשת במידה רבה באותם קלטים (משתנים אחידים ומאגרי אחסון) כמו צינור הרינדור. אז אולי תחשבו שאפשר פשוט להשתמש באותן קבוצות של קישורים ולסיים את זה, נכון? החדשות הטובות הן שאפשר! פשוט צריך לבצע עוד קצת הגדרה ידנית כדי להפעיל את האפשרות הזו.
בכל פעם שיוצרים קבוצת קישור, צריך לספק GPUBindGroupLayout. בעבר, כדי לקבל את הפריסה הזו, הייתם צריכים להפעיל את getBindGroupLayout() בצינור העיבוד, והמערכת הייתה יוצרת אותה באופן אוטומטי כי סיפקתם את layout: "auto" כשנוצרה. הגישה הזו מתאימה אם משתמשים רק בצינור אחד, אבל אם יש כמה צינורות שרוצים לשתף משאבים, צריך ליצור את הפריסה באופן מפורש, ואז לספק אותה גם לקבוצת ההתקשרות וגם לצינורות.
כדי להבין למה, כדאי לשים לב: בצינורות העיבוד שלכם אתם משתמשים במאגר אחיד יחיד ובמאגר אחסון יחיד, אבל ב-compute shader שכתבתם עכשיו, אתם צריכים מאגר אחסון שני. מכיוון ששני ה-shaders משתמשים באותם ערכי @binding עבור ה-uniform ומאגר האחסון הראשון, אפשר לשתף אותם בין צינורות, וצינור העיבוד מתעלם ממאגר האחסון השני, שהוא לא משתמש בו. אתם רוצים ליצור פריסה שמתארת את כל המשאבים שקיימים בקבוצת ההתאמה, ולא רק את אלה שנמצאים בשימוש בצינור ספציפי.
- כדי ליצור את הפריסה הזו, קוראים ל-
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 שמציינים אילו שלבי תוכנת הצללה יכולים להשתמש במשאב. אתם רוצים שגם מאגר האחסון האחיד וגם מאגר האחסון הראשון יהיו נגישים ב-vertex וב-compute shaders, אבל מאגר האחסון השני צריך להיות נגיש רק ב-compute shaders.
לבסוף, מציינים את סוג המשאב שבו נעשה שימוש. זהו מפתח מילון שונה, בהתאם למה שרוצים לחשוף. במקרה הזה, כל שלושת המשאבים הם מאגרי נתונים זמניים, ולכן משתמשים במקש buffer כדי להגדיר את האפשרויות לכל אחד מהם. אפשר גם להשתמש באפשרויות אחרות כמו texture או sampler, אבל לא צריך אותן כאן.
במילון המאגר, מגדירים אפשרויות כמו type המאגר שבו משתמשים. ערך ברירת המחדל הוא "uniform", לכן אפשר להשאיר את המילון ריק כדי לבצע קישור של 0. (עם זאת, צריך להגדיר לפחות buffer: {} כדי שהערך יזוהה כמאגר). ל-Binding 1 מוקצה סוג של "read-only-storage" כי לא משתמשים בו עם גישה מסוג read_write ב-Shader, ול-Binding 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 ב-shaders. (כלומר, 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
}]
}
});
יצירת צינור עיבוד נתונים
בדיוק כמו שצריך צינור עיבוד כדי להשתמש ב-vertex shader וב-fragment shader, צריך צינור עיבוד כדי להשתמש ב-compute shader. למזלנו, צינורות לחישוב הרבה פחות מסובכים מצינורות לעיבוד, כי אין בהם מצב להגדרה, רק את תוכנת ההצללה והפריסה.
- יוצרים צינור עיבוד נתונים באמצעות הקוד הבא:
index.html
// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
label: "Simulation pipeline",
layout: pipelineLayout,
compute: {
module: simulationShaderModule,
entryPoint: "computeMain",
}
});
שימו לב שמעבירים את pipelineLayout החדש במקום "auto", בדיוק כמו בצינור עיבוד הנתונים המעודכן, וכך מוודאים שגם צינור עיבוד הנתונים וגם צינור החישוב יכולים להשתמש באותן קבוצות קשירה.
כרטיסים לחישוב
כך מגיעים לשלב שבו משתמשים בפועל בצינור העיבוד. בהתחשב בכך שאתם מבצעים את הרינדור במעבר רינדור, אתם יכולים לנחש שאתם צריכים לבצע עבודת חישוב במעבר חישוב. אפשר לבצע את העבודה של החישוב והרינדור באותו מקודד פקודות, ולכן כדאי לשנות קצת את הפונקציה 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...
בדומה לצינורות של Compute, הרבה יותר פשוט להפעיל מעברים של Compute מאשר את המקבילים שלהם של Rendering, כי לא צריך לדאוג לגבי קבצים מצורפים.
מומלץ להריץ את שלב החישוב לפני שלב העיבוד, כי כך שלב העיבוד יכול להשתמש באופן מיידי בתוצאות האחרונות משלב החישוב. זו גם הסיבה לכך שמגדילים את הערך של 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 ב-Shader.
אם רוצים שתוכנת ההצללה תפעל 32x32 פעמים כדי לכסות את כל הרשת, וגודל קבוצת העבודה הוא 8x8, צריך לשלוח קבוצות עבודה בגודל 4x4 (4 * 8 = 32). לכן מחלקים את גודל הרשת בגודל קבוצת העבודה ומעבירים את הערך הזה אל dispatchWorkgroups().
עכשיו אפשר לרענן שוב את הדף, ותראו שהרשת מתהפכת בכל עדכון.

הטמעת האלגוריתם של משחק החיים
לפני שמעדכנים את shader החישוב כדי להטמיע את האלגוריתם הסופי, כדאי לחזור לקוד שמאתחל את תוכן מאגר האחסון ולעדכן אותו כך שייווצר מאגר אקראי בכל טעינת דף. (דפוסים רגילים לא יוצרים נקודות התחלה מעניינות במיוחד במשחק החיים). אפשר להגריל את הערכים איך שרוצים, אבל יש דרך פשוטה להתחיל שנותנת תוצאות סבירות.
- כדי שכל תא יתחיל במצב אקראי, צריך לעדכן את האתחול של
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);
עכשיו אפשר סוף סוף להטמיע את הלוגיקה של סימולציית משחק החיים. אחרי כל מה שעברנו כדי להגיע לכאן, יכול להיות שקוד ה-Shader יהיה פשוט באופן מפתיע.
קודם כול, צריך לדעת כמה מהתאים השכנים פעילים. לא חשוב לכם אילו חשבונות פעילים, אלא רק המספר שלהם.
- כדי להקל על קבלת נתונים מתאים סמוכים, מוסיפים פונקציה
cellActiveשמחזירה את הערךcellStateInשל הקואורדינטה הנתונה.
index.html (Compute createShaderModule call)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
הפונקציה cellActive מחזירה 1 אם התא פעיל, לכן אם מוסיפים את הערך המוחזר של קריאה לפונקציה cellActive עבור כל שמונת התאים הסמוכים, מקבלים את מספר התאים הסמוכים שפעילים.
- כדי לראות את מספר השכנים הפעילים, פועלים לפי השלבים הבאים:
index.html (קריאה ל-Compute createShaderModule)
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines:
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
אבל זה מוביל לבעיה קטנה: מה קורה אם התא שבודקים נמצא מחוץ לקצה הלוח? לפי הלוגיקה של cellIndex() כרגע, הוא גולש לשורה הבאה או הקודמת, או שהוא חורג מהקצה של המאגר!
במשחק החיים, דרך נפוצה ופשוטה לפתור את הבעיה הזו היא להגדיר שהתאים בקצה הרשת יתייחסו לתאים בקצה הנגדי של הרשת כשכנים שלהם, וכך ליצור מעין אפקט של מעבר מקצה לקצה.
- תמיכה בהתאמה אוטומטית של רשתות עם שינוי קל בפונקציה
cellIndex().
index.html (קריאה ל-Compute createShaderModule)
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;
}
}
לשם השוואה, הקריאה הסופית למודול של shader לחישוב נראית עכשיו כך:
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. מעולה!
יצרת גרסה של סימולציית המשחק הקלאסי 'משחק החיים' של קונוויי, שפועלת כולה ב-GPU באמצעות WebGPU API!
מה השלב הבא?
- כדאי לעיין בדוגמאות ל-WebGPU