אפליקציית WebGPU הראשונה

1. מבוא

הלוגו של WebGPU מורכב מכמה משולשים כחולים שיוצרים את האות W מסוגננת

מה זה 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 כדי לבצע את הסימולציה.

צילום מסך של המוצר הסופי של ה-Codelab הזה

המשחק 'החיים' הוא מה שנקרא אוטומט תאי, שבו רשת של תאים משנה את המצב שלה לאורך זמן על סמך קבוצה מסוימת של כללים. במשחק החיים, התאים הופכים לפעילים או ללא פעילים בהתאם למספר התאים השכנים הפעילים שלהם, מה שמוביל לדפוסים מעניינים שמשתנים בזמן הצפייה.

מה תלמדו

  • איך מגדירים 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.

  1. כדי לבדוק אם האובייקט 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 במכשיר.

  1. כדי לקבל מתאם, משתמשים בשיטה navigator.gpu.requestAdapter(). הפונקציה מחזירה אובייקט promise, ולכן הכי נוח לקרוא לה באמצעות 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.

  1. אפשר לקבל את המכשיר באמצעות קריאה ל- adapter.requestDevice(), שגם מחזירה אובייקט promise.

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 שמנחות אותו מה לעשות.

  1. כדי לעשות את זה, המכשיר יוצר GPUCommandEncoder, שמספק ממשק להקלטת פקודות GPU.

index.html

const encoder = device.createCommandEncoder();

הפקודות שרוצים לשלוח ל-GPU קשורות לרינדור (במקרה הזה, ניקוי של אזור הציור), ולכן השלב הבא הוא להשתמש ב-encoder כדי להתחיל Render Pass.

שלבי הרינדור הם השלבים שבהם מתבצעות כל פעולות הציור ב-WebGPU. כל אחד מהם מתחיל בקריאה ל-beginRenderPass(), שמגדירה את הטקסטורות שמקבלות את הפלט של כל פקודות הציור שמבוצעות. שימושים מתקדמים יותר יכולים לספק כמה טקסטורות, שנקראות קבצים מצורפים, למטרות שונות כמו אחסון העומק של גיאומטריה מעובדת או מתן החלקה. אבל לאפליקציה הזו צריך רק אחד.

  1. מקבלים את הטקסטורה מהקשר של בד הציור שיצרתם קודם באמצעות קריאה ל-context.getCurrentTexture(), שמחזירה טקסטורה עם רוחב וגובה בפיקסלים שתואמים למאפיינים width ו-height של בד הציור ול-format שצוין כשקראתם ל-context.configure().

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
     view: context.getCurrentTexture().createView(),
     loadOp: "clear",
     storeOp: "store",
  }]
});

המרקם מוגדר במאפיין view של colorAttachment. כדי להשתמש ב-Render passes, צריך לספק GPUTextureView במקום GPUTexture, כדי לציין לאילו חלקים של הטקסטורה לבצע רנדר. ההגדרה הזו חשובה רק בתרחישי שימוש מתקדמים יותר, ולכן כאן קוראים ל-createView() ללא ארגומנטים בטקסטורה, כדי לציין שרוצים שמסלול העיבוד ישתמש בטקסטורה כולה.

צריך גם לציין מה רוצים שמעבר הרינדור יעשה עם הטקסטורה כשהוא מתחיל וכשהוא מסתיים:

  • ערך loadOp של "clear" מציין שרוצים לנקות את הטקסטורה כשתהליך העיבוד מתחיל.
  • ערך storeOp של "store" מציין שרוצים לשמור את התוצאות של כל ציור שבוצע במהלך שלב העיבוד לטקסטורה אחרי ששלב העיבוד מסתיים.

אחרי שתהליך הרינדור יתחיל, לא צריך לעשות כלום. לפחות בינתיים. הפעולה של התחלת שלב הרינדור עם loadOp: "clear" מספיקה כדי לנקות את תצוגת הטקסטורה ואת הקנבס.

  1. כדי לסיים את שלב הרינדור, מוסיפים את הקריאה הבאה מיד אחרי beginRenderPass():

index.html

pass.end();

חשוב לדעת שביצוע השיחות האלה לא גורם ל-GPU לעשות משהו בפועל. הם רק מקליטים פקודות כדי שה-GPU יבצע אותן מאוחר יותר.

  1. כדי ליצור GPUCommandBuffer, קוראים ל-finish() במקודד הפקודות. מאגר הנתונים הזמני של הפקודות הוא נקודת אחיזה אטומה לפקודות שתועדו.

index.html

const commandBuffer = encoder.finish();
  1. שולחים את מאגר הפקודות ל-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() כדי לקבל טקסטורה חדשה למעבר רינדור.

  1. טוענים מחדש את הדף. שימו לב שהקנבס מלא בשחור. מעולה! זה אומר שיצרתם בהצלחה את האפליקציה הראשונה שלכם ב-WebGPU.

בדף יופיע קנבס שחור שמציין שהשתמשתם ב-WebGPU כדי לנקות את התוכן של הקנבס.

בוחרים צבע!

אבל האמת היא שריבועים שחורים הם די משעממים. לכן, כדאי להקדיש רגע לפני שעוברים לקטע הבא כדי להתאים אותו אישית.

  1. בשיחת encoder.beginRenderPass(), מוסיפים שורה חדשה עם clearValue ל-colorAttachment, כך:

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),
    loadOp: "clear",
    clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
    storeOp: "store",
  }],
});

הפקודה clearValue מורה למעבר העיבוד באיזה צבע להשתמש כשמבצעים את הפעולה clear בתחילת המעבר. המילון שמועבר אליה מכיל ארבעה ערכים: r עבור אדום, g עבור ירוק, b עבור כחול ו-a עבור אלפא (שקיפות). כל ערך יכול להיות בטווח שבין 0 ל-1, והערכים האלה ביחד מתארים את הערך של ערוץ הצבע הזה. לדוגמה:

  • הצבע של { r: 1, g: 0, b: 0, a: 1 } הוא אדום בוהק.
  • { r: 1, g: 0, b: 1, a: 1 } בצבע סגול בהיר.
  • { r: 0, g: 0.3, b: 0, a: 1 } הוא ירוק כהה.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } הוא אפור בינוני.
  • { r: 0, g: 0, b: 0, a: 0 } הוא ערך ברירת המחדל, שחור שקוף.

בדוגמאות הקוד ובצילומי המסך ב-Codelab הזה נעשה שימוש בכחול כהה, אבל אתם יכולים לבחור כל צבע שתרצו.

  1. אחרי שבוחרים צבע, טוענים מחדש את הדף. הצבע שבחרתם אמור להופיע באזור העריכה.

בדוגמה הזו, הרקע של אזור העריכה נמחק והצבע שלו שונה לכחול כהה כדי להמחיש איך משנים את צבע המחיקה שמוגדר כברירת מחדל.

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, כותבים את השיידרים האלה, והם מספקים לכם שליטה רבה באופן הפעולה של ה-GPU.

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

התוצאות של צבעי הפיקסלים האלה מצטברות ויוצרות מרקם, שאפשר להציג במסך.

הגדרת קודקודים

כפי שצוין קודם, סימולציית משחק החיים מוצגת כרשת של תאים. באפליקציה צריך להיות אמצעי להצגת הרשת, שמאפשר להבחין בין תאים פעילים לתאים לא פעילים. ב-Codelab הזה נשתמש בגישה של ציור ריבועים צבעוניים בתאים פעילים והשארת תאים לא פעילים ריקים.

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

גרף של קואורדינטות מכשיר מנורמלות שמציג קואורדינטות של פינות ריבוע

כדי להעביר את הקואורדינטות האלה ל-GPU, צריך להציב את הערכים ב-TypedArray. אם אתם לא מכירים את TypedArrays, אלה קבוצה של אובייקטים ב-JavaScript שמאפשרים להקצות בלוקים רציפים של זיכרון ולפרש כל אלמנט בסדרה כסוג נתונים ספציפי. לדוגמה, ב-Uint8Array, כל רכיב במערך הוא בייט יחיד לא חתום. ‫TypedArrays מצוינים לשליחת נתונים הלוך ושוב עם ממשקי API שרגישים לפריסת הזיכרון, כמו WebAssembly,‏ WebAudio ו-WebGPU (כמובן).

בדוגמה של הריבוע, מכיוון שהערכים הם שברים, מתאים להשתמש ב-Float32Array.

  1. יוצרים מערך שמכיל את כל מיקומי הקודקודים בתרשים על ידי הצבת הצהרת המערך הבאה בקוד. מומלץ למקם אותו קרוב לחלק העליון, ממש מתחת לשיחה context.configure().

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8,
   0.8, -0.8,
   0.8,  0.8,
  -0.8,  0.8,
]);

שימו לב שהרווח וההערה לא משפיעים על הערכים, הם רק נועדו להקל עליכם ולקריאה נוחה יותר. הוא עוזר לכם לראות שכל זוג ערכים מרכיב את קואורדינטות ה-X וה-Y של קודקוד אחד.

אבל יש בעיה! יחידות GPU פועלות במונחים של משולשים, זוכרים? לכן, צריך לספק את הקודקודים בקבוצות של שלושה. יש לך קבוצה אחת של ארבעה. הפתרון הוא לחזור על שתיים מהנקודות כדי ליצור שני משולשים שחולקים צלע באמצע הריבוע.

תרשים שמראה איך ארבעת הקודקודים של הריבוע ישמשו ליצירת שני משולשים.

כדי ליצור את הריבוע מהתרשים, צריך לרשום את הקודקודים (‎-0.8, -0.8) ו- (0.8, 0.8) פעמיים, פעם אחת עבור המשולש הכחול ופעם אחת עבור המשולש האדום. (אפשר גם לבחור לפצל את הריבוע עם שתי הפינות האחרות, זה לא משנה).

  1. מעדכנים את המערך הקודם vertices כך שייראה בערך כך:

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8, // Triangle 1 (Blue)
   0.8, -0.8,
   0.8,  0.8,

  -0.8, -0.8, // Triangle 2 (Red)
   0.8,  0.8,
  -0.8,  0.8,
]);

למרות שבתרשים יש הפרדה בין שני המשולשים לשם הבהרה, מיקומי הקודקודים זהים לחלוטין, וה-GPU מעבד אותם ללא רווחים. הוא יוצג כריבוע אחיד.

יצירת מאגר זמני של ורטקסים

ה-GPU לא יכול לצייר קודקודים עם נתונים ממערך JavaScript. לרוב, למעבדי GPU יש זיכרון משלהם שעבר אופטימיזציה גבוהה לעיבוד תמונה, ולכן כל נתון שרוצים שמעבד ה-GPU ישתמש בו בזמן שהוא מצייר צריך להיות ממוקם בזיכרון הזה.

עבור הרבה ערכים, כולל נתוני קודקודים, הזיכרון בצד ה-GPU מנוהל באמצעות אובייקטים מסוג GPUBuffer. מאגר הוא בלוק זיכרון שה-GPU יכול לגשת אליו בקלות, והוא מסומן למטרות מסוימות. אפשר לחשוב על זה קצת כמו על TypedArray שגלוי ל-GPU.

  1. כדי ליצור מאגר זמני לאחסון הקודקודים, מוסיפים את הקריאה הבאה אל 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 שרוצים להעתיק.

  1. כדי להעתיק את נתוני הקודקוד לזיכרון של המאגר, מוסיפים את הקוד הבא:

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 כמחרוזת. הערה זו עשויה להופיע לצד סרטון זה אם היא תמצא מועילה על ידי בודקי צד שלישי. הערות אינן מבטאות את דעותיה של Google. אחרי שמוסיפים קוד WGSL תקין, הפונקציה מחזירה אובייקט GPUShaderModule עם התוצאות המהודרות.

הגדרת הצללה של קודקסי Vertex

מתחילים עם תוכנת הצללה של קודקודים כי שם מתחיל גם ה-GPU.

vertex shader מוגדר כפונקציה, וה-GPU קורא לפונקציה הזו פעם אחת לכל קודקוד ב-vertexBuffer. מכיוון שלצורה vertexBuffer יש שישה מיקומים (קודקודים), הפונקציה שהגדרתם נקראת שש פעמים. בכל פעם שהיא נקראת, מועבר לפונקציה מיקום אחר מתוך vertexBuffer כארגומנט, והתפקיד של פונקציית הצללה של קודקסי צורה הוא להחזיר מיקום תואם במרחב הקליפ.

חשוב להבין שהן לא בהכרח יופעלו בסדר עוקב. לעומת זאת, מעבדי GPU מצטיינים בהרצת הצללות כאלה במקביל, ויכולים לעבד מאות (או אפילו אלפים!) של קודקודים בו-זמנית. זהו חלק חשוב מאוד במהירות המדהימה של מעבדי GPU, אבל יש לזה מגבלות. כדי להבטיח מקסימום הקבלה, שיידרים של קודקסי צלעות לא יכולים לתקשר ביניהם. כל הפעלה של Shader יכולה לראות נתונים של קודקוד אחד בלבד בכל פעם, והיא יכולה להפיק ערכים רק לקודקוד אחד.

ב-WGSL, אפשר לתת לפונקציית vertex shader כל שם שרוצים, אבל צריך להוסיף לפניה את @vertex attribute כדי לציין את שלב ההצללה שהיא מייצגת. ב-WGSL, מציינים פונקציות באמצעות מילת המפתח fn, משתמשים בסוגריים כדי להצהיר על ארגומנטים, ובסוגריים מסולסלים כדי להגדיר את ההיקף.

  1. יוצרים פונקציה ריקה @vertex, כמו בדוגמה הבאה:

index.html (createShaderModule code)

@vertex
fn vertexMain() {

}

עם זאת, זה לא תקין, כי shader של קודקוד חייב להחזיר לפחות את המיקום הסופי של הקודקוד שעובר עיבוד במרחב קליפ. הערך הזה תמיד ניתן כווקטור 4-ממדי. וקטורים הם דבר כל כך נפוץ לשימוש ב-shaders, שהם נחשבים לפרימיטיבים ברמה גבוהה בשפה, עם סוגים משלהם כמו vec4f עבור וקטור 4-ממדי. יש גם סוגים דומים לווקטורים דו-ממדיים (vec2f) ותלת-ממדיים (vec3f).

  1. כדי לציין שהערך שמוחזר הוא המיקום הנדרש, צריך להוסיף לו את המאפיין @builtin(position). הסמל -> מציין את הערך שהפונקציה מחזירה.

index.html (קוד createShaderModule)

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

}

כמובן, אם לפונקציה יש סוג החזרה, צריך להחזיר ערך בגוף הפונקציה. אפשר ליצור vec4f חדש להחזרה באמצעות התחביר vec4f(x, y, z, w). הערכים x,‏ y ו-z הם מספרים עם נקודה צפה, שמציינים בערך המוחזר את המיקום של הקודקוד במרחב החיתוך.

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

  1. משנים את פונקציית ה-shader לקוד הבא:

index.html (קוד createShaderModule)

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

עכשיו צריך להחזיר את המיקום הזה. מכיוון שהמיקום הוא וקטור דו-ממדי וסוג ההחזרה הוא וקטור תלת-ממדי, צריך לשנות אותו קצת. מה שאתם רוצים לעשות זה לקחת את שני הרכיבים מארגומנט המיקום ולהציב אותם בשני הרכיבים הראשונים של וקטור ההחזרה, ולהשאיר את שני הרכיבים האחרונים כ-0 ו-1, בהתאמה.

  1. כדי להחזיר את המיקום הנכון, צריך לציין במפורש באילו רכיבי מיקום להשתמש:

index.html (קוד createShaderModule)

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

עם זאת, מכיוון שמיפויים מהסוג הזה נפוצים מאוד ב-shaders, אפשר גם להעביר את וקטור המיקום כארגומנט הראשון בקיצור נוח, והמשמעות היא אותה משמעות.

  1. כותבים מחדש את ההצהרה return באמצעות הקוד הבא:

index.html (קוד createShaderModule)

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

זהו ה-vertex shader הראשוני שלכם. היא מאוד פשוטה, היא פשוט מעבירה את המיקום בלי לשנות אותו, אבל היא מספיק טובה כדי להתחיל איתה.

הגדרת 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.

  1. יוצרים פונקציה ריקה @fragment, כמו בדוגמה הבאה:

index.html (קוד createShaderModule)

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

}

ארבעת הרכיבים של הווקטור שמוחזר הם ערכי הצבעים אדום, ירוק, כחול ואלפא, שמפורשים בדיוק כמו clearValue שהגדרתם ב-beginRenderPass קודם. לכן vec4f(1, 0, 0, 1) הוא אדום בוהק, שנראה כמו צבע טוב לריבוע. אבל אתם יכולים להגדיר אותו בכל צבע שתרצו.

  1. מגדירים את וקטור הצבע שמוחזר, כך:

index.html (קוד createShaderModule)

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

וזהו, סיימתם לכתוב את תוכנת הצללת המקטעים! זה לא קוד מעניין במיוחד, הוא פשוט מגדיר את כל הפיקסלים של כל המשולשים לאדום, אבל זה מספיק בשלב הזה.

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

לבסוף, יש פרטים על השלב fragment. הוא כולל גם מודול של Shader ו-entryPoint, כמו שלב הקודקוד. החלק האחרון הוא להגדיר את targets שבו משתמשים בפייפליין הזה. זו מערך של מילונים עם פרטים – כמו המרקם format – של קבצים מצורפים של צבע שהצינור מוציא. הפרטים האלה צריכים להיות זהים לטקסטורות שמופיעות ב-colorAttachments של כל שלבי הרינדור שבהם נעשה שימוש בפייפליין הזה. ה-render pass משתמש בטקסטורות מהקונטקסט של ה-canvas, ומשתמש בערך ששמרתם ב-canvasFormat בשביל הפורמט שלו, לכן אתם מעבירים את אותו הפורמט כאן.

אלה רק חלק מהאפשרויות שאפשר לציין כשיוצרים פייפליין רינדור, אבל הן מספיקות לצרכים של ה-Codelab הזה.

שרטוט הריבוע

ועכשיו יש לכם את כל מה שצריך כדי לצייר את הריבוע.

  1. כדי לצייר את הריבוע, חוזרים אל צמד השיחות encoder.beginRenderPass() ו-pass.end(), ומוסיפים ביניהן את הפקודות החדשות האלה:

index.html

// After encoder.beginRenderPass()

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices

// before pass.end()

כך מספקים ל-WebGPU את כל המידע שנדרש כדי לצייר את הריבוע. קודם כל, משתמשים ב- setPipeline() כדי לציין באיזה צינור להשתמש כדי לצייר. המידע הזה כולל את ההצללות שבהן נעשה שימוש, הפריסה של נתוני הקודקודים ונתוני מצב רלוונטיים אחרים.

בשלב הבא, מתקשרים אל setVertexBuffer() עם המאגר שמכיל את הקודקודים של הריבוע. קוראים לו 0 כי המאגר הזה תואם לאלמנט מספר 0 בהגדרה של vertex.buffers בצינור הנוכחי.

ולבסוף, מבצעים את השיחה באמצעות draw(), שנראית פשוטה באופן מוזר אחרי כל ההגדרה שבוצעה לפני כן. הדבר היחיד שצריך לקבוע ערך (pass) הוא מספר הקודקודים שצריך לרנדר, והוא נשלף ממאגרי הקודקודים שהוגדרו כרגע ומפורש באמצעות צינור העיבוד שהוגדר כרגע. אפשר פשוט להגדיר את הערך כ-6, אבל אם מחשבים אותו ממערך הקודקודים (12 מספרים ממשיים חלקי 2 קואורדינטות לכל קודקוד שווה ל-6 קודקודים), זה אומר שאם תחליטו להחליף את הריבוע, למשל בעיגול, יהיה פחות מה לעדכן באופן ידני.

  1. מרעננים את המסך ורואים (סוף סוף) את התוצאות של כל העבודה הקשה: ריבוע גדול וצבעוני.

ריבוע אדום יחיד שעבר רינדור באמצעות WebGPU

5. ציור רשת

קודם כול, כדאי לפרגן לעצמך! אחד השלבים הקשים ביותר ברוב ממשקי ה-API של GPU הוא הצגת הנתונים הגיאומטריים הראשונים על המסך. כל מה שאתם עושים מכאן אפשר לעשות בשלבים קטנים יותר, וכך קל יותר לבדוק את ההתקדמות שלכם תוך כדי.

בקטע הזה מוסבר:

  • איך מעבירים משתנים (שנקראים אחידים) לשיידר מ-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 

הפעולה הזו מגדירה אחידות בשיידר שנקרא 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) שהקלדתם בתוכנת הצללה (shader).

אחרי שמציינים את הפריסה, מספקים מערך של entries. כל רשומה היא מילון עם הערכים הבאים לפחות:

  • binding, שמתאים לערך @binding() שהזנתם ב-Shader. במקרה הזה, 0.
  • resource, שהוא המשאב בפועל שרוצים לחשוף למשתנה באינדקס הקישור שצוין. במקרה הזה, מאגר אחיד.

הפונקציה מחזירה GPUBindGroup, שהוא אובייקט אטום ובלתי ניתן לשינוי. אחרי שיוצרים קבוצת קשירה, אי אפשר לשנות את המשאבים שהיא מפנה אליהם, אבל אפשר לשנות את התוכן של המשאבים האלה. לדוגמה, אם משנים את מאגר הנתונים האחיד כך שיכיל גודל רשת חדש, השינוי ישתקף בקריאות עתידיות לציור באמצעות קבוצת ההתאמה הזו.

קישור קבוצת הקישור

אחרי שיוצרים את קבוצת הקישור, עדיין צריך להגדיר ב-WebGPU שימוש בקבוצה הזו בזמן הציור. למזלנו, זה די פשוט.

  1. חוזרים אל 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) שלך!

  1. מרעננים את הדף, ואז אמור להופיע משהו כזה:

ריבוע אדום קטן במרכז רקע כחול כהה.

מעולה! הריבוע שלך הוא עכשיו רבע מהגודל שהיה קודם! זה לא הרבה, אבל זה מראה שהמדים מוחלים בפועל ושה-shader יכול עכשיו לגשת לגודל הרשת.

שינוי גיאומטריה ב-Shader

עכשיו, כשאתם יכולים להפנות לגודל הרשת בתוכנת הצללה (shader), אתם יכולים להתחיל לעבוד על שינוי הגיאומטריה שאתם מבצעים רינדור שלה כדי להתאים אותה לתבנית הרשת הרצויה. כדי לעשות את זה, חשבו בדיוק מה אתם רוצים להשיג.

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

איור של הרשת הקונספטואלית שאליה יחולק מרחב הקואורדינטות של המכשיר הנורמלי כשמציגים חזותית כל תא עם הגיאומטריה הריבועית שמוצגת כרגע במרכז שלו.

האתגר שלכם הוא למצוא שיטה בתוכנת הצללה (shader) שתאפשר לכם למקם את הגיאומטריה של הריבוע בכל אחד מהתאים האלה, בהינתן הקואורדינטות של התא.

קודם כול, אפשר לראות שהריבוע לא מיושר בצורה טובה עם אף אחת מהתאים, כי הוא הוגדר להקיף את מרכז בד הציור. כדאי להזיז את הריבוע בחצי תא כדי שהוא יתאים בדיוק בתוך התאים.

אחת הדרכים לפתור את הבעיה היא לעדכן את מאגר הקודקודים של הריבוע. אם משנים את הקודקודים כך שהפינה השמאלית התחתונה תהיה למשל בנקודה (0.1, 0.1) במקום בנקודה (‎-0.8, ‎-0.8), הריבוע יזוז ויתיישר בצורה טובה יותר עם גבולות התא. אבל מכיוון שיש לכם שליטה מלאה באופן העיבוד של הקודקודים בתוכנת ההצללה (shader), אפשר פשוט להזיז אותם למקום באמצעות קוד תוכנת ההצללה (shader).

  1. משנים את מודול הצללת הקודקוד באמצעות הקוד הבא:

index.html (קריאה ל-createShaderModule)

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

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Add 1 to the position before dividing by the grid size.
  let gridPos = (pos + 1) / grid;

  return vec4f(gridPos, 0, 1);
}

כל קודקוד זז למעלה ולימין ב-1 (שזה, כזכור, חצי ממרחב החיתוך) לפני שמחלקים אותו בגודל הרשת. התוצאה היא ריבוע שמוצב בצורה יפה על הרשת, קצת רחוק מנקודת המקור.

המחשה של אזור הציור שמחולק באופן קונספטואלי לרשת של 4x4 עם ריבוע אדום בתא (2, 2)

בשלב הבא, מכיוון שבמערכת הקואורדינטות של אזור הציור (canvas) הערך (0, 0) ממוקם במרכז והערך (‎-1, -1) ממוקם בפינה השמאלית התחתונה, ואתם רוצים שהערך (0, 0) יהיה בפינה השמאלית התחתונה, אתם צריכים להזיז את המיקום של הצורה הגיאומטרית ב-‎(-1, -1) אחרי החלוקה בגודל הרשת, כדי להעביר אותה לפינה הזו.

  1. כך מתרגמים את המיקום של הגיאומטריה:

index.html (קריאה ל-createShaderModule)

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

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Subtract 1 after dividing by the grid size.
  let gridPos = (pos + 1) / grid - 1;

  return vec4f(gridPos, 0, 1); 
}

ועכשיו הריבוע ממוקם בצורה יפה בתא (0, 0).

תמונה שמציגה את אזור העריכה מחולק ל-16 ריבועים (4x4), עם ריבוע אדום בתא (0, 0)

מה קורה אם רוצים להציב אותו בתא אחר? כדי לעשות את זה, צריך להצהיר על וקטור cell בשיידר ולאכלס אותו בערך סטטי כמו let cell = vec2f(1, 1).

אם מוסיפים את זה ל-gridPos, הפעולה הזו מבטלת את - 1 באלגוריתם, ולכן לא כדאי לעשות את זה. במקום זאת, רוצים להזיז את הריבוע רק ביחידת רשת אחת (רבע מהאזור הציור) לכל תא. נראה שצריך לחלק שוב ב-grid!

  1. משנים את המיקום של הרשת, כך:

index.html (קריאה ל-createShaderModule)

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

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1); // Cell(1,1) in the image above
  let cellOffset = cell / grid; // Compute the offset to cell
  let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!

  return vec4f(gridPos, 0, 1);
}

אם תרעננו עכשיו, תראו את הדברים הבאים:

תצוגה חזותית של בד הציור שמחולק באופן מושגי לרשת 4x4 עם ריבוע אדום שממורכז בין תא (0, 0), תא (0, 1), תא (1, 0) ותא (1, 1)

הממ. לא בדיוק מה שרצית.

הסיבה לכך היא שהקואורדינטות של הקנבס נעות מ-‎-1 עד ‎+1, ולכן הרוחב הוא בעצם 2 יחידות. כלומר, אם רוצים להזיז קודקוד לרבע מהרוחב של אזור הציור, צריך להזיז אותו ב-0.5 יחידות. קל מאוד לטעות כשמנסים להבין את הקואורדינטות של ה-GPU. למזלכם, התיקון פשוט באותה מידה.

  1. מכפילים את ההיסט ב-2, כך:

index.html (קריאה ל-createShaderModule)

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

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

כך תקבלו בדיוק את מה שאתם רוצים.

תרשים להמחשה של אזור העריכה שמחולק באופן קונספטואלי לרשת של 4x4 עם ריבוע אדום בתא (1, 1)

צילום המסך נראה כך:

צילום מסך של ריבוע אדום על רקע כחול כהה. הריבוע האדום מצויר באותו מיקום כמו בתרשים הקודם, אבל בלי שכבת הרשת.

בנוסף, עכשיו אפשר להגדיר את cell לכל ערך בגבולות הרשת, ואז לרענן כדי לראות את הריבוע במיקום הרצוי.

שרטוט מופעים

עכשיו, אחרי שאתם יכולים למקם את הריבוע במקום הרצוי באמצעות חישובים מתמטיים, השלב הבא הוא להציג ריבוע אחד בכל תא ברשת.

אחת הדרכים לעשות את זה היא לכתוב את הקואורדינטות של התאים למאגר אחיד, ואז לקרוא לפונקציה draw פעם אחת לכל ריבוע ברשת, ולעדכן את המאגר האחיד בכל פעם. אבל זה יהיה מאוד איטי, כי ה-GPU יצטרך לחכות בכל פעם עד שקואורדינטה חדשה תיכתב על ידי JavaScript. אחד מהגורמים שמשפיעים על הביצועים של ה-GPU הוא משך הזמן שהוא מבלה בהמתנה לחלקים אחרים במערכת.

במקום זאת, אפשר להשתמש בטכניקה שנקראת יצירת מופעים. ה-Instancing הוא דרך להורות ל-GPU לצייר כמה עותקים של אותה גיאומטריה באמצעות קריאה אחת ל-draw, וזה הרבה יותר מהיר מאשר קריאה ל-draw פעם אחת לכל עותק. כל עותק של הגיאומטריה נקרא מופע.

  1. כדי להגיד ל-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.

  1. משתמשים ב-instance במקום בקואורדינטות של התא:

index.html

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

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {
  
  let i = f32(instance); // Save the instance_index as a float
  let cell = vec2f(i, i); // Updated
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

אם תרעננו עכשיו, תראו שיש לכם יותר מריבוע אחד. אבל לא תוכלו לראות את כל 16 התמונות.

ארבעה ריבועים אדומים בקו אלכסוני מהפינה השמאלית התחתונה אל הפינה הימנית העליונה על רקע כחול כהה.

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

תרשים להמחשה של אזור העריכה שמחולק באופן קונספטואלי לרשת 4x4, כאשר כל תא תואם גם לאינדקס של מופע לינארי.

החישוב של זה פשוט יחסית. לכל ערך X בתא, רוצים את המודולו של instance_index ורוחב הרשת, שאפשר לבצע ב-WGSL באמצעות האופרטור %. ולגבי כל ערך Y של תא, מחלקים את instance_index ברוחב הרשת ומתעלמים משארית השבר. אפשר לעשות את זה באמצעות הפונקציה floor() של WGSL.

  1. משנים את החישובים, כך:

index.html

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

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {

  let i = f32(instance);
  // Compute the cell coordinate from the instance_index
  let cell = vec2f(i % grid.x, floor(i / grid.x));

  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

אחרי שמבצעים את העדכון הזה בקוד, סוף סוף מקבלים את רשת הריבועים המיוחלת!

ארבע שורות של ארבע עמודות של ריבועים אדומים על רקע כחול כהה.

  1. עכשיו שהיא עובדת, אפשר לחזור ולהגדיל את גודל הרשת.

index.html

const GRID_SIZE = 32;

‫32 שורות של 32 עמודות של ריבועים אדומים על רקע כחול כהה.

טדה! עכשיו אפשר להגדיל את הרשת הזו מאוד, והמעבד הגרפי הממוצע יתמודד איתה בלי בעיה. הריבועים הנפרדים ייעלמו הרבה לפני שתיתקלו בצווארי בקבוק בביצועים של ה-GPU.

6. בונוס: תן לזה מראה צבעוני יותר!

בשלב הזה, אתם יכולים לדלג בקלות לקטע הבא, כי הנחתם את הבסיס לכל שאר ה-Codelab. אבל למרות שהרשת של הריבועים שכולם באותו צבע היא שימושית, היא לא בדיוק מרגשת, נכון? למזלכם, אפשר להבהיר את התמונה בעזרת קצת מתמטיקה וקוד הצללה.

שימוש במבני נתונים (structs) ב-shaders

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

הדרך היחידה להעביר נתונים מחוץ ל-vertex shader היא להחזיר אותם. תמיד צריך להחזיר מיקום באמצעות Vertex Shader, ולכן אם רוצים להחזיר נתונים אחרים יחד עם המיקום, צריך להציב אותם במבנה. מבנים ב-WGSL הם סוגי אובייקטים עם שמות שמכילים מאפיין אחד או יותר עם שמות. אפשר גם להוסיף למאפיינים מאפיינים כמו @builtin ו-@location. מגדירים אותם מחוץ לפונקציות, ואז אפשר להעביר מופעים שלהם אל תוך הפונקציות ומחוצה להן, לפי הצורך. לדוגמה, נניח שיש לכם את תוכנת ההצללה (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 לפני שמחזירים.

  1. משנים את ערך ההחזרה של הצללת הקודקוד, כך:

index.html (קריאה ל-createShaderModule)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
  @location(0) cell: vec2f, // New line!
};

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

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell; // New line!
  return output;
}
  1. בפונקציה @fragment, מקבלים את הערך על ידי הוספת ארגומנט עם אותו @fragment.@location (השמות לא חייבים להיות זהים, אבל קל יותר לעקוב אחרי הדברים אם הם זהים).

index.html (קריאה ל-createShaderModule)

@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
  // Remember, fragment return values are (Red, Green, Blue, Alpha)
  // and since cell is a 2D vector, this is equivalent to:
  // (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
  return vec4f(cell, 0, 1);
}
  1. לחלופין, אפשר להשתמש במבנה במקום זאת:

index.html (קריאה ל-createShaderModule)

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

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. אפשרות נוספת, מכיוון ששתי הפונקציות האלה מוגדרות באותו מודול של Shader בקוד, היא לעשות שימוש חוזר במבנה הפלט של שלב @vertex. כך קל להעביר ערכים כי השמות והמיקומים עקביים באופן טבעי.

index.html (קריאה ל-createShaderModule)

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

לא משנה באיזה דפוס תבחרו, התוצאה היא שתהיה לכם גישה למספר התא בפונקציה @fragment ותוכלו להשתמש בו כדי להשפיע על הצבע. הפלט של כל אחד מהקודים שלמעלה נראה כך:

A grid of squares where the leftmost column is green, the bottom row is red, and all other squares are yellow.

יש עכשיו יותר צבעים, אבל זה לא נראה טוב במיוחד. יכול להיות שתהיתם למה רק השורות השמאלית והתחתונה שונות. הסיבה לכך היא שערכי הצבע שמוחזרים מהפונקציה @fragment מצפים שכל ערוץ יהיה בטווח 0 עד 1, וכל ערך מחוץ לטווח הזה מוגבל לטווח הזה. לעומת זאת, הערכים בתאים נעים בין 0 ל-32 לאורך כל ציר. אז מה שרואים כאן זה שהשורה והעמודה הראשונות מגיעות מיד לערך המלא 1 בערוץ הצבע האדום או הירוק, וכל תא אחרי זה מוגבל לאותו ערך.

אם רוצים מעבר חלק יותר בין הצבעים, צריך להחזיר ערך חלקי לכל ערוץ צבעים, רצוי להתחיל מאפס ולסיים באחד לאורך כל ציר, מה שאומר שצריך לחלק שוב ב-grid.

  1. משנים את הצללית של השבר, כך:

index.html (קריאה ל-createShaderModule)

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

אם תרעננו את הדף, תוכלו לראות שהקוד החדש כן יוצר מעבר צבעים נעים יותר בכל הרשת.

רשת של ריבועים שמשנים צבעים מפינה לפינה: שחור, אדום, ירוק וצהוב.

זה בהחלט שיפור, אבל עכשיו יש פינה שחורה לא מוצלחת בצד שמאל למטה, שבה הרשת הופכת לשחורה. כשמתחילים להריץ את הסימולציה של משחק החיים, חלק מהרשת שקשה לראות מסתיר את מה שקורה. הייתי רוצה להבהיר את התמונה.

למזלכם, יש לכם ערוץ צבעים שלם שלא נמצא בשימוש – כחול – שבו אתם יכולים להשתמש. האפקט שרוצים להשיג הוא שהצבע הכחול יהיה הבהיר ביותר במקומות שבהם הצבעים האחרים הם הכהים ביותר, ואז ידהה ככל שהצבעים האחרים יגברו בעצימות. הדרך הכי קלה לעשות את זה היא להתחיל את הערוץ ב-1 ולהפחית אחד מהערכים בתא. הערך יכול להיות c.x או c.y. אפשר לנסות את שניהם ולבחור את זה שמתאים לכם יותר.

  1. מוסיפים צבעים בהירים יותר ל-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 לחישוב.

למזלנו, יש אפשרות אחרת להגדרת מאגר זמני שלא כוללת את כל המגבלות האלה.

יצירת מאגר אחסון

מאגרי אחסון הם מאגרים לשימוש כללי שאפשר לקרוא ולכתוב בהם בתוכנות הצללה (shaders) של חישוב, ולקרוא בהם בתוכנות הצללה (shaders) של קודקודים. הן יכולות להיות גדולות מאוד, ולא צריך להצהיר על גודל ספציפי שלהן בשיידר, מה שהופך אותן לדומות הרבה יותר לזיכרון כללי. זה מה שמשמש לאחסון מצב התא.

  1. כדי ליצור מאגר אחסון למצב התא, משתמשים בקטע קוד ליצירת מאגר, שסביר להניח שהוא כבר מוכר לכם:

index.html

// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);

// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
  label: "Cell State",
  size: cellStateArray.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

בדומה למאגרי הנתונים של הקודקודים והנתונים האחידים, קוראים לפונקציה device.createBuffer() עם הגודל המתאים, ואז מקפידים לציין שימוש ב-GPUBufferUsage.STORAGE הפעם.

אפשר לאכלס את מאגר הנתונים הזמני באותה דרך כמו קודם: ממלאים את TypedArray באותו גודל עם ערכים ואז קוראים ל-device.queue.writeBuffer(). כדי לראות את ההשפעה של המאגר על הרשת, מתחילים במילוי שלו במשהו צפוי.

  1. מפעילים כל תא שלישי באמצעות הקוד הבא:

index.html

// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);

קריאת מאגר האחסון ב-Shader

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

  1. מעדכנים את ה-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 מבטל אותה.

  1. מעדכנים את קוד ה-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); 
  1. כדי ליצור שני מאגרי נתונים זהים, אפשר להשתמש בדפוס הזה בקוד שלכם ולעדכן את הקצאת מאגר הנתונים.

index.html

// Create two storage buffers to hold the cell state.
const cellStateStorage = [
  device.createBuffer({
    label: "Cell State A",
    size: cellStateArray.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  }),
  device.createBuffer({
    label: "Cell State B",
     size: cellStateArray.byteLength,
     usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  })
];
  1. כדי להמחיש את ההבדל בין שני המאגרים, אפשר למלא אותם בנתונים שונים:

index.html

// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
  cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
  1. כדי להציג את מאגרי האחסון השונים בעיבוד, צריך לעדכן את קבוצות ההתאמה כך שיהיו להן גם שתי וריאציות שונות:

index.html

const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
   device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }],
  })
];

הגדרת לולאת רינדור

עד עכשיו, ביצעתם רק ציור אחד בכל רענון דף, אבל עכשיו אתם רוצים להציג נתונים שמתעדכנים לאורך זמן. כדי לעשות את זה, צריך לולאת רינדור פשוטה.

לולאת רינדור היא לולאה שחוזרת על עצמה ללא סוף ומציירת את התוכן שלכם באזור הציור במרווח מסוים. במשחקים ובתכנים אחרים שרוצים להציג אנימציה חלקה, משתמשים בפונקציה requestAnimationFrame() כדי לתזמן קריאות חוזרות (callback) באותו קצב שבו המסך מתרענן (60 פעמים בכל שנייה).

האפליקציה הזו יכולה להשתמש גם בזה, אבל במקרה הזה, כדאי שהעדכונים יתבצעו בשלבים ארוכים יותר כדי שיהיה לכם קל יותר לעקוב אחרי מה שהסימולציה עושה. במקום זאת, אפשר לנהל את הלולאה בעצמכם כדי לקבוע את קצב העדכון של הסימולציה.

  1. קודם בוחרים את קצב העדכון של הסימולציה (200 אלפיות השנייה זה טוב, אבל אפשר לבחור קצב איטי או מהיר יותר אם רוצים), ואז עוקבים אחרי מספר השלבים שהושלמו בסימולציה.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. לאחר מכן מעבירים את כל הקוד שמשמש כרגע לעיבוד לפונקציה חדשה. מתזמנים את הפונקציה לחזרה במרווח הרצוי באמצעות setInterval(). חשוב לוודא שהפונקציה מעדכנת גם את מספר השלבים, ומשתמשת בו כדי לבחור איזו מבין שתי קבוצות הקישור לקשר.

index.html

// Move all of our rendering code into a function
function updateGrid() {
  step++; // Increment the step count
  
  // Start a render pass 
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      loadOp: "clear",
      clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
      storeOp: "store",
    }]
  });

  // Draw the grid.
  pass.setPipeline(cellPipeline);
  pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
  pass.setVertexBuffer(0, vertexBuffer);
  pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

  // End the render pass and submit the command buffer
  pass.end();
  device.queue.submit([encoder.finish()]);
}

// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);

עכשיו, כשמריצים את האפליקציה, אפשר לראות שהקנבס מתהפך הלוך ושוב בין שני מאגרי המידע של המצב שיצרתם.

פסים אלכסוניים של ריבועים צבעוניים שמתחילים בפינה השמאלית התחתונה ומסתיימים בפינה הימנית העליונה על רקע כחול כהה. פסים אנכיים של ריבועים צבעוניים על רקע כחול כהה.

בשלב הזה, סיימתם את רוב העבודה שקשורה לעיבוד התמונות. הכול מוכן להצגת הפלט של סימולציית משחק החיים שתיצרו בשלב הבא, שבו סוף סוף תתחילו להשתמש ב-compute shaders.

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

8. הרצת הסימולציה

עכשיו, נותר לנו החלק האחרון והחשוב בפאזל: ביצוע הסימולציה של משחק החיים באמצעות 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.

  1. יוצרים 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.

  1. מגדירים קבוע לגודל קבוצת העבודה, כך:

index.html

const WORKGROUP_SIZE = 8;

צריך גם להוסיף את הגודל של קבוצת העבודה לפונקציית ה-shader עצמה, באמצעות תבניות מילוליות של JavaScript, כדי שתוכלו להשתמש בקבוע שהגדרתם בקלות.

  1. מוסיפים את גודל קבוצת העבודה לפונקציית ה-shader, כך:

index.html (Compute createShaderModule call)

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

}

ההצהרה הזו אומרת ל-shader שהעבודה שמתבצעת באמצעות הפונקציה הזו מתבצעת בקבוצות של (8 x 8 x 1). (כל ציר שלא מציינים מקבל כברירת מחדל את הערך 1, אבל צריך לציין לפחות את ציר X).

בדומה לשלבי הצללה אחרים, יש מגוון של @builtin ערכים שאפשר לקבל כקלט לפונקציית הצללה של מחשוב כדי לדעת באיזה הפעלה אתם נמצאים ולהחליט איזו עבודה צריך לבצע.

  1. מוסיפים ערך @builtin, כך:

index.html (Compute createShaderModule call)

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

  1. משתמשים ב-uniform עם shader של מחשוב כדי לציין את גודל הרשת, כך:

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

}

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

  1. כך חושפים את מצב הקלט והפלט של התא כמאגרי אחסון:

index.html (Compute createShaderModule call)

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

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

}

שימו לב שהמאגר הראשון של האחסון מוצהר עם var<storage>, מה שהופך אותו לקריאה בלבד, אבל המאגר השני של האחסון מוצהר עם var<storage, read_write>. כך תוכלו לקרוא ולכתוב למאגר נתונים זמני, ולהשתמש במאגר נתונים זמני הזה כפלט של תוכנת הצללה (shader) החישוב. (אין מצב אחסון לקריאה בלבד ב-WebGPU).

לאחר מכן, צריך למצוא דרך למפות את אינדקס התא למערך האחסון הלינארי. זה בעצם ההפך ממה שעשיתם ב-vertex shader, שבו לקחתם את instance_index הליניארי ומיפיתם אותו לתא ברשת דו-ממדית. (רצינו להזכיר לך שהאלגוריתם שלך היה vec2f(i % grid.x, floor(i / grid.x))).

  1. כתיבת פונקציה שתפעל בכיוון ההפוך. הפונקציה מקבלת את ערך ה-Y של התא, מכפילה אותו ברוחב הרשת ואז מוסיפה את ערך ה-X של התא.

index.html (Compute createShaderModule call)

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

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

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

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

ולבסוף, כדי לוודא שהיא פועלת, מטמיעים אלגוריתם פשוט מאוד: אם תא מופעל כרגע, הוא מושבת, ולהפך. זה לא המשחק'החיים' עדיין, אבל זה מספיק כדי להראות ש-compute shader פועל.

  1. מוסיפים את האלגוריתם הפשוט, כך:

index.html (Compute createShaderModule call)

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

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

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

זהו, סיימתם לבינתיים עם shader החישוב. אבל לפני שתוכלו לראות את התוצאות, יש עוד כמה שינויים שצריך לבצע.

שימוש בפריסות של קבוצות כבילה וצינורות עיבוד נתונים

דבר אחד שאפשר לשים לב אליו בתוכנת ההצללה (shader) שלמעלה הוא שהוא משתמש במידה רבה באותם קלטים (משתנים אחידים ומאגרי אחסון) כמו פייפליין העיבוד שלכם. אז אולי תחשבו שאפשר פשוט להשתמש באותן קבוצות של קישורים ולסיים את זה, נכון? החדשות הטובות הן שאפשר לעשות את זה. פשוט צריך לבצע עוד קצת הגדרה ידנית כדי להפעיל את האפשרות הזו.

בכל פעם שיוצרים קבוצת קישור, צריך לספק GPUBindGroupLayout. בעבר, כדי לקבל את הפריסה הזו, הייתם צריכים להפעיל את getBindGroupLayout() בצינור העיבוד, והמערכת הייתה יוצרת אותה באופן אוטומטי כי סיפקתם את layout: "auto" כשנוצרה. הגישה הזו מתאימה אם משתמשים רק בפייפליין אחד, אבל אם יש כמה פייפליינים שרוצים לשתף משאבים, צריך ליצור את הפריסה באופן מפורש, ואז לספק אותה גם לקבוצת הקישור וגם לפייפליינים.

כדי להבין למה, כדאי לשים לב: בצינורות העיבוד שלכם אתם משתמשים במאגר אחיד יחיד ובמאגר אחסון יחיד, אבל ב-compute shader שכתבתם עכשיו, אתם צריכים מאגר אחסון שני. מכיוון ששני השיידרים משתמשים באותם ערכי @binding עבור המאגר האחיד ומאגר האחסון הראשון, אפשר לשתף אותם בין צינורות, וצינור העיבוד מתעלם ממאגר האחסון השני, שהוא לא משתמש בו. אתם רוצים ליצור פריסה שמתארת את כל המשאבים שקיימים בקבוצת ההתאמה, ולא רק את אלה שנמצאים בשימוש בצינור ספציפי.

  1. כדי ליצור את הפריסה הזו, קוראים ל-device.createBindGroupLayout():

index.html

// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
  label: "Cell Bind Group Layout",
  entries: [{
    binding: 0,
    // Add GPUShaderStage.FRAGMENT here if you are using the `grid` uniform in the fragment shader.
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: {} // Grid uniform buffer
  }, {
    binding: 1,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: { type: "read-only-storage"} // Cell state input buffer
  }, {
    binding: 2,
    visibility: GPUShaderStage.COMPUTE,
    buffer: { type: "storage"} // Cell state output buffer
  }]
});

המבנה של הפעולה הזו דומה למבנה של יצירת קבוצת הקישור עצמה, כי צריך לתאר רשימה של entries. ההבדל הוא שבמקום לספק את המשאב עצמו, אתם מתארים את סוג המשאב שצריך להיות הרשומה ואיך משתמשים בו.

בכל רשומה מציינים את המספר binding של המשאב, שזהה לערך @binding בשיידרים (כפי שלמדתם כשיצרתם את קבוצת ההגדרה). אתם מספקים גם את visibility, שהם דגלי GPUShaderStage שמציינים אילו שלבי הצללה יכולים להשתמש במשאב. אתם רוצים שגם מאגר האחסון האחיד וגם מאגר האחסון הראשון יהיו נגישים ב-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, אפשר להעביר אותו כשיוצרים את קבוצות הכבילה במקום לשלוח שאילתה לגבי קבוצת הכבילה מצינור הנתונים. כדי לעשות את זה, צריך להוסיף רשומה חדשה של מאגר אחסון לכל קבוצת איגוד כדי להתאים לפריסה שהגדרתם.

  1. מעדכנים את יצירת קבוצת ההצמדה, כך:

index.html

// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: bindGroupLayout, // Updated Line
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[1] }
    }],
  }),
  device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: bindGroupLayout, // Updated Line

    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
];

ועכשיו, אחרי שקבוצת הקישור עודכנה כדי להשתמש בפריסת קבוצת הקישור המפורשת הזו, צריך לעדכן את צינור הרינדור כדי להשתמש באותו דבר.

  1. יצירת GPUPipelineLayout.

index.html

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

פריסת צינור היא רשימה של פריסות קבוצות איגוד (במקרה הזה, יש אחת) שצינור אחד או יותר משתמשים בהן. הסדר של פריסות קבוצת הכריכה במערך צריך להתאים למאפיינים @group ב-shaders. (כלומר, bindGroupLayout משויך ל-@group(0)).

  1. אחרי שיוצרים את פריסת צינור עיבוד הנתונים, מעדכנים את צינור עיבוד הנתונים של הרינדור כדי להשתמש בו במקום ב-"auto".

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: pipelineLayout, // Updated!
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

יצירת צינור עיבוד נתונים

בדיוק כמו שצריך פייפליין רינדור כדי להשתמש ב-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.

  1. מעבירים את יצירת המקודד לחלק העליון של הפונקציה, ואז מתחילים איתו מעבר חישוב (לפני step++).

index.html

// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

// Existing lines
step++; // Increment the step count
  
// Start a render pass...

בדומה לצינורות עיבוד נתונים, הרבה יותר פשוט להפעיל מעברי עיבוד נתונים מאשר את מקביליהם של הרינדור, כי לא צריך לדאוג לגבי קבצים מצורפים.

כדאי להריץ את שלב החישוב לפני שלב העיבוד, כי כך שלב העיבוד יכול להשתמש באופן מיידי בתוצאות האחרונות משלב החישוב. זו גם הסיבה לכך שמגדילים את הערך של step בין המעברים, כך שמאגר הפלט של צינור עיבוד הנתונים הופך למאגר הקלט של צינור העיבוד.

  1. לאחר מכן, מגדירים את הפייפליין ואת קבוצת הקישור בתוך שלב החישוב, באמצעות אותו דפוס למעבר בין קבוצות קישור כמו בשלב הרינדור.

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. לבסוף, במקום לצייר כמו במעבר רינדור, אתם שולחים את העבודה לשיידר החישוב, ומציינים כמה קבוצות עבודה אתם רוצים להפעיל בכל ציר.

index.html

const computePass = encoder.beginComputePass();

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

// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);

computePass.end();

חשוב מאוד לציין שהמספר שמעבירים אל dispatchWorkgroups() לא מייצג את מספר הקריאות לפונקציה. במקום זאת, זהו מספר קבוצות העבודה שיש להריץ, כפי שמוגדר על ידי @workgroup_size ב-Shader.

אם רוצים שה-shader יפעל 32x32 פעמים כדי לכסות את כל הרשת, וגודל קבוצת העבודה הוא 8x8, צריך לשלוח קבוצות עבודה בגודל 4x4 (4 * 8 = 32). לכן מחלקים את גודל הרשת בגודל קבוצת העבודה ומעבירים את הערך הזה אל dispatchWorkgroups().

עכשיו אפשר לרענן שוב את הדף, ותראו שהרשת מתהפכת בכל עדכון.

פסים אלכסוניים של ריבועים צבעוניים שמתחילים בפינה השמאלית התחתונה ומסתיימים בפינה הימנית העליונה על רקע כחול כהה. פסים אלכסוניים של ריבועים צבעוניים ברוחב של שני ריבועים, שמתחילים בצד שמאל למטה ומסתיימים בצד ימין למעלה על רקע כחול כהה. היפוך של התמונה הקודמת.

הטמעת האלגוריתם של משחק החיים

לפני שמעדכנים את shader החישוב כדי להטמיע את האלגוריתם הסופי, כדאי לחזור לקוד שמאתחל את תוכן מאגר האחסון ולעדכן אותו כך שייווצר מאגר אקראי בכל טעינת דף. (דפוסים רגילים לא יוצרים נקודות התחלה מעניינות במיוחד במשחק החיים). אפשר להגריל את הערכים איך שרוצים, אבל יש דרך פשוטה להתחיל שנותנת תוצאות סבירות.

  1. כדי שכל תא יתחיל במצב אקראי, צריך לעדכן את האתחול של cellStateArray לקוד הבא:

index.html

// Set each cell to a random state, then copy the JavaScript array 
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
  cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

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

קודם כול, צריך לדעת כמה מהתאים השכנים פעילים. לא חשוב לכם אילו חשבונות פעילים, אלא רק המספר שלהם.

  1. כדי להקל על קבלת נתונים מתאים סמוכים, מוסיפים פונקציה cellActive שמחזירה את הערך cellStateIn של הקואורדינטה הנתונה.

index.html (Compute createShaderModule call)

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

הפונקציה cellActive מחזירה 1 אם התא פעיל, לכן אם מוסיפים את הערך המוחזר של קריאה לפונקציה cellActive עבור כל שמונת התאים הסמוכים, מקבלים את מספר התאים הסמוכים שפעילים.

  1. כדי לראות את מספר השכנים הפעילים, פועלים לפי השלבים הבאים:

index.html (Compute createShaderModule call)

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

אבל זה מוביל לבעיה קטנה: מה קורה אם התא שבודקים נמצא מחוץ לקצה הלוח? לפי הלוגיקה של cellIndex() כרגע, הוא גולש לשורה הבאה או הקודמת, או שהוא חורג מהקצה של המאגר!

במשחק החיים, דרך נפוצה ופשוטה לפתור את הבעיה הזו היא להגדיר שהתאים בקצה הרשת יתייחסו לתאים בקצה הנגדי של הרשת כשכנים שלהם, וכך ליצור מעין אפקט של מעבר מקצה לקצה.

  1. תמיכה בהתאמה אוטומטית של רשתות עם שינוי קל בפונקציה cellIndex().

index.html (Compute createShaderModule call)

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

כשמשתמשים באופרטור % כדי להקיף את התאים X ו-Y כשהם חורגים מגודל הרשת, מוודאים שלעולם לא תהיה גישה מחוץ לגבולות של מאגר האחסון. כך תוכלו להיות בטוחים שמספר המשתמשים ב-activeNeighbors צפוי.

אחר כך תוכלו להחיל אחת מארבעה כללים:

  • כל תא עם פחות משני תאים שכנים הופך ללא פעיל.
  • כל תא פעיל עם שניים או שלושה תאים שכנים פעילים נשאר פעיל.
  • כל תא לא פעיל עם שלושה תאים שכנים בדיוק הופך לפעיל.
  • כל תא עם יותר משלושה תאים שכנים הופך ללא פעיל.

אפשר לעשות את זה באמצעות סדרה של משפטי if, אבל WGSL תומכת גם במשפטי switch, שמתאימים מאוד ללוגיקה הזו.

  1. מטמיעים את הלוגיקה של 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!

מה השלב הבא?

קריאה נוספת

מאמרי עזרה