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

1. מבוא

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

מהו WebGPU?

WebGPU הוא ממשק API חדש ומודרני לגישה ליכולות של ה-GPU באפליקציות אינטרנט.

API מודרני

לפני WebGPU, הייתה השיטה WebGL שהציע קבוצת משנה של התכונות של WebGPU. הוא הפעיל מחלקה חדשה של תוכן עשיר באינטרנט, ומפתחים בנו דברים מדהימים באמצעותו. עם זאת, הוא התבסס על ממשק ה-API של OpenGL ES 2.0, שפורסם בשנת 2007, והתבסס על ממשק ה-API של OpenGL, שהיה ישן עוד יותר. מעבדי ה-GPU התפתחו באופן משמעותי באותה תקופה, וממשקי ה-API המקוריים ששימשו לממשק איתם התפתחו גם עם Direct3D 12 , Metal ו-Vulkan.

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

רינדור

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

Compute

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

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

מה תפַתחו

ב-Codelab הזה, בונים את Conway's Game of Life באמצעות WebGPU. האפליקציה שלך:

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

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

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

מה תלמדו

  • איך להגדיר את WebGPU ולהגדיר אזור עריכה.
  • איך לצייר גיאומטריה פשוטה דו-ממדית.
  • איך משתמשים ב-vertex shader וב-fragment shader כדי לשנות את מה שמצויר.
  • כיצד להשתמש בצללים למחשוב כדי לבצע סימולציה פשוטה.

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

מה צריך להכין

  • גרסה עדכנית של Chrome (בגרסה 113 ואילך) ב-ChromeOS, ב-macOS או ב-Windows. WebGPU הוא ממשק API בפלטפורמות ובדפדפנים שונים, אבל הוא עדיין לא זמין בכל מקום.
  • ידע ב-HTML, ב-JavaScript וב-כלי הפיתוח ל-Chrome.

לא חובה להכיר ממשקי API אחרים של גרפיקה, כמו WebGL,‏ Metal,‏ Vulkan או Direct3D, אבל אם יש לכם ניסיון בהם, סביר להניח שתבחינו בהרבה דמיון ל-WebGPU, שיכול לעזור לכם להתחיל ללמוד את הנושא.

2. להגדרה

קבלת הקוד

ב-codelab הזה אין יחסי תלות, והוא כולל הדרכה מפורטת בכל שלב נדרש ליצירת אפליקציית WebGPU, כך שאין צורך בקוד כדי להתחיל. עם זאת, יש כמה דוגמאות שפועלות שיכולות לשמש כנקודות עצירה בכתובת https://glitch.com/edit/#!/your-first-webgpu-app. אפשר לעיין בהן ולהיעזר בהן תוך כדי העבודה, אם נתקעים.

משתמשים ב-Developer Console!

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

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

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

3. איך מאתחלים את WebGPU

מתחילים ב-<canvas>

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

יוצרים מסמך HTML חדש עם רכיב <canvas> יחיד, וגם תג <script> שבו אנחנו שולחים שאילתה לרכיב הקנבס. (אפשר גם להשתמש ב-00-starter-page.html מ-glitch).

  • יוצרים קובץ index.html עם הקוד הבא:

index.html

<!doctype html>

<html>
  <head>
    <meta charset="utf-8">
    <title>WebGPU Life</title>
  </head>
  <body>
    <canvas width="512" height="512"></canvas>
    <script type="module">
      const canvas = document.querySelector("canvas");

      // Your WebGPU code will begin here!
    </script>
  </body>
</html>

בקשת מתאם ומכשיר

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

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

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

ניקוי הקנבס

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

כדי לעשות את זה, או כמעט כל דבר אחר ב-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. כשעוברים עיבוד, צריך לספק 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 מורה ל-render pass באיזה צבע להשתמש כשמבצעים את הפעולה clear בתחילת ה-pass. המילון שהועבר אליו מכיל ארבעה ערכים: r עבור אדום, g לירוק, b לכחול ו-a ל-alpha (שקיפות). כל ערך יכול לנוע בין 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

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

בניגוד לממשק API כמו בד ציור 2D שיש בו הרבה צורות ואפשרויות שמתאימות לשימוש, ה-GPU למעשה מטפל רק בכמה סוגים שונים של צורות (או פרימיטיבים, כפי שהם מכונים ב-WebGPU): נקודות, קווים ומשולשים. לצורכי הקודלאב הזה, נשתמש רק במשולשים.

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

הנקודות האלה, או הקודקודים, ניתנות במונחים של ערכי X, Y ו-Z, שמגדירים נקודה במערכת קואורדינטות קרטזית שהוגדרה על ידי WebGPU או ממשקי API דומים. הכי קל לחשוב על המבנה של מערכת הקואורדינטות מבחינת האופן שבו היא קשורה לאזור העריכה בדף. לא משנה כמה רחבה או גבוהה היא, הקצה הימני שלה תמיד נמצא ב-1+ בציר X, והקצה הימני תמיד נמצא ב-1- בציר X. באופן דומה, הקצה התחתון הוא תמיד -1 על ציר ה-Y, והקצה העליון הוא +1 על ציר ה-Y. כלומר, (0, 0) תמיד נמצא במרכז הלוח, (-1, -1) תמיד נמצא בפינה השמאלית התחתונה ו-(1, 1) תמיד נמצא בפינה השמאלית העליונה. האירוע הזה נקרא Clip Space.

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

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

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

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

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

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

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

תרשים מנורמל של &#39;ניהול מיקום של מכשיר&#39; שמציג קואורדינטות של פינות של ריבוע

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

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

המעבד הגרפי לא יכול לצייר קודקודים עם נתונים ממערך JavaScript. ל-GPUs יש לרוב זיכרון משלהם שמותאם במיוחד לעיבוד (רנדרינג), ולכן כל נתון שרוצים שה-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 בייטים. כדי לקבוע אותו, מכפילים את הגודל של צף צף ( 4 בייטים) במספר הצפים במערך vertices (12). למרבה המזל, TypedArrays כבר מחשבים את byteLength שלהם בשבילכם, כך שתוכלו להשתמש בנתון הזה כשיוצרים את המאגר.

לסיום, צריך לציין את השימוש במאגר הנתונים הזמני. זהו אחד או יותר מהדגלים GPUBufferUsage, כאשר כמה דגלים משולבים באמצעות האופרטור | (bitwise OR). במקרה הזה צריך לציין שרוצים שהמאגר הנתונים הזמני ישמש לנתוני קודקודים (GPUBufferUsage.VERTEX) ושאפשר גם להעתיק אליו נתונים (GPUBufferUsage.COPY_DST).

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

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

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

index.html

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

הגדרה של פריסת הקודקוד

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

  • מגדירים את מבנה הנתונים של הקודקוד באמצעות מילון GPUVertexBufferLayout:

index.html

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

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

הדבר הראשון שצריך לספק הוא arrayStride. זהו מספר הבייטים ש-GPU צריך לדלג קדימה במאגר כשמחפש את הנקודה הבאה. כל קודקוד בריבוע שלך מורכב משני מספרים נקודה צפה (floating-point) של 32 ביט. כפי שציינו קודם, ציפה של 32 ביט היא 4 בייט, כך ששני בייטים הם 8 בייטים.

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

במאפיין היחיד, מגדירים קודם את format של הנתונים. הוא מגיע מרשימת סוגי GPUVertexFormat שמתארים כל סוג של נתוני קודקוד ש-GPU יכול להבין. לכל קודקוד יש שני מספרי 32 ביט שמוצגים כ-float, ולכן צריך להשתמש בפורמט float32x2. אם נתוני הנקודות מורכבים מ-4 מספרים שלמים ללא סימן באורך 16 ביט כל אחד, למשל, צריך להשתמש ב-uint16x4 במקום זאת. ראית את הדפוס?

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

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

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

התחלה עם תוכנות הצללה

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

אזורים כהים הם תוכנות קטנות שכותבים ומופעלות ב-GPU. כל תוכנת הצללה פועלת בשלב שונה של הנתונים: עיבוד Vertex, עיבוד Fragment או Compute כללי. מכיוון שהן נמצאות ב-GPU, הן מובנות בצורה נוקשה יותר מ-JavaScript ממוצע. אבל המבנה הזה מאפשר להריץ אותם מהר מאוד, ובעיקר במקביל!

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

ה-shaders עצמם מועברים ל-WebGPU כמחרוזות.

  • יוצרים מקום שבו מזינים את קוד הצללה על ידי העתקת הקוד הבא לקוד שמתחת ל-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 עם התוצאות שעברו הידור.

הגדרת הכלי להצללה של קודקוד

כדאי להתחיל עם תוכנת ההצללה (shader) של הקודקוד כי כאן גם ה-GPU מתחיל!

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

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

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

  1. יוצרים פונקציית @vertex ריקה, למשל:

index.html (קוד createShaderModule)

@vertex
fn vertexMain() {

}

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

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

index.html (קוד createShaderModule)

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

}

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

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

index.html (קוד createShaderModule)

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

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

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

index.html (קוד createShaderModule)

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

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

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

index.html (קוד createShaderModule)

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

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

הגדרת הפונקציה fragment shader

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

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

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

פונקציה של תוכנת ההצללה (shader) של מקטעים WGSL מסומנת באמצעות המאפיין @fragment וגם מחזירה vec4f. במקרה הזה, הווקטור מייצג צבע, ולא מיקום. צריך להקצות למאפיין המוחזר את המאפיין @location כדי לציין לאיזה colorAttachment מהקריאה ל-beginRenderPass הצבע המוחזר יירשם. מכיוון שהיה לכם רק קובץ מצורף אחד, המיקום הוא 0.

  1. יוצרים פונקציית @fragment ריקה, למשל:

index.html (קוד createShaderModule)

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

}

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

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

index.html (קוד createShaderModule)

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

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

  • יוצרים צינור עיבוד נתונים לעיבוד, כמו בדוגמה הבאה:

index.html

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

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

בשלב הבא, תצטרכו לספק פרטים על השלב vertex. הערך module הוא מודול ה-GPUShader שמכיל את שדה הקוד של Vertex Shader, והערך entryPoint מספק את שם הפונקציה בקוד שדה הקוד שמופעל בכל קריאה ל-Vertex. (אפשר להשתמש במספר פונקציות @vertex ו-@fragment במודול תוכנת הצללה (shader) אחד!) buffers הוא מערך של אובייקטים מסוג GPUVertexBufferLayout שמתארים איך הנתונים ארוזים במאגרי הנקודות שבהם אתם משתמשים בצינור עיבוד הנתונים הזה. למזלכם, כבר הגדרתם את זה קודם לכן ב-vertexBufferLayout. כאן מעבירים אותו.

לבסוף, מופיעים פרטים על השלב fragment. זה כולל גם מודול ו-entryPoint, כמו שלב הקודקוד. השלב האחרון הוא להגדיר את targets שבו נעשה שימוש בצינור עיבוד הנתונים הזה. זוהי מערך של מילונים שמספקים פרטים – כמו המרקם format – של צירופי הצבעים שהצינור פולט. הפרטים האלה צריכים להתאים למרקמים שצוינו ב-colorAttachments של כל שלבי העיבוד שעבורם נעשה שימוש בצינור עיבוד הנתונים הזה. כרטיס העיבוד משתמש במרקמים מההקשר של אזור העריכה, ומשתמש בערך ששמרתם ב-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() כדי לציין באיזה צינור עיבוד נתונים צריך להשתמש לצורך ציור. המידע הזה כולל את השכבות לשיפור התמונה (shaders) שבהן נעשה שימוש, את הפריסה של נתוני הנקודות (vertices) ונתוני מצב רלוונטיים אחרים.

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

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

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

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

5. שרטוט של רשת

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

בקטע הזה תלמדו:

  • איך מעבירים משתנים (שנקראים uniforms) לשיחדר מ-JavaScript.
  • איך להשתמש במדים כדי לשנות את התנהגות הרינדור.
  • איך משתמשים ביצירת מכונות (instancing) כדי לצייר גרסאות רבות ושונות של אותה גיאומטריה.

הגדרת הרשת

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

  • כדי להגדיר את גודל הרשת, מוסיפים קבוע בחלק העליון של קוד ה-JavaScript.

index.html

const GRID_SIZE = 4;

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

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

יצירת מאגר נתונים זמני

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

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

הקוד הזה אמור להיראות מוכר מאוד, כי הוא כמעט זהה לקוד שבו השתמשתם כדי ליצור את מאגר הנקודות הקודקודיות מקודם. הסיבה לכך היא ש-uniforms מועברים ל-WebGPU API דרך אובייקטים של GPUBuffer, כמו קודקודים. ההבדל העיקרי הוא שבפעם הזו, usage כולל את GPUBufferUsage.UNIFORM במקום את GPUBufferUsage.VERTEX.

גישה למשתני uniform ב-shader

  • כדי להגדיר תלבושת אחידה, מוסיפים את הקוד הבא:

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

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

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

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

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

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

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

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

עכשיו מאגר הנתונים האחיד חשוף לתוכנת ההצללה!

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

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

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

שינוי הגיאומטריה בשיידר

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

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

איור של הרשת הרעיונית של המרחב המנורמל של &#39;ניהול צוותים במכשיר&#39; יחולק בעת הצגה חזותית של כל תא עם הגיאומטריה המרובעת המוצגת כרגע במרכזו.

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

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

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

  1. משנים את המודול של תוכנת ההצללה (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);
}

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

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

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

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

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

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

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

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

  return vec4f(gridPos, 0, 1); 
}

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

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

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

index.html

pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

כך מציינים למערכת שרוצים שהיא תצייר את ששת (vertices.length / 2) הקודקודים של הריבוע 16 (GRID_SIZE * GRID_SIZE) פעמים. אבל אם מרעננים את הדף, עדיין מוצגים הפרטים הבאים:

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

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

בשדר, בנוסף למאפייני הנקודות (vertices) כמו pos שמגיעים מאגר הנקודות, אפשר לגשת גם לערכים המובנים של WGSL. אלה ערכים שמחושבים על ידי WebGPU, ואחד מהערכים האלה הוא instance_index. instance_index הוא מספר לא חתום של 32 ביט מ-0 עד number of instances - 1, ואפשר להשתמש בו כחלק מהלוגיקה של תוכנת ההצללה. הערך שלו זהה לכל קודקוד שמעובד כחלק מאותה מכונה. כלומר, פונקציית vertex shader נקראת שש פעמים עם instance_index של 0, פעם לכל מיקום במאגר הנקודות. לאחר מכן עוד שש פעמים עם instance_index מתוך 1, ואז שש פעמים נוספות עם instance_index מתוך 2, וכן הלאה.

כדי לראות את זה בפעולה, צריך להוסיף את instance_index המובנה לקלט של תוכנת ההצללה (shader). מבצעים את הפעולה הזו באותו אופן כמו במיקום, אבל במקום לתייג אותו באמצעות מאפיין @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 של תא צריך להגדיר את ה-modulo של instance_index ואת רוחב הרשת, שאפשר לבצע ב-WGSL באמצעות האופרטור %. לכל ערך Y של תא, צריך לחלק את instance_index ברוחב התא, ולהתעלם מכל שארית עשרונית. אפשר לעשות זאת באמצעות הפונקציה floor() של WGSL.

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

index.html

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

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

index.html

const GRID_SIZE = 32;

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

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

6. קרדיט נוסף: יותר צבעוניות!

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

שימוש באבני בניין בתוכנות הצללה

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

הדרך היחידה להעביר נתונים מתוך תוכנת ההצללה (shader) של הקודקוד היא להחזיר אותם. תמיד צריך להחזיר מיקום מ-vertex shader, כך שאם רוצים להחזיר נתונים אחרים יחד איתו, צריך להציב אותו ב-struct. מבני נתונים ב-WGSL הם סוגי אובייקטים עם שם שמכילים מאפיין אחד או יותר עם שם. אפשר לסמן את המאפיינים באמצעות מאפיינים כמו @builtin ו-@location גם כן. אפשר להצהיר עליהם מחוץ לפונקציות כלשהן, ואז אפשר להעביר מופעים שלהם בפונקציות או מחוץ להן, לפי הצורך. לדוגמה, נבחן את תוכנת ההצללה הנוכחית של קודקוד:

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

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

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

  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;
  
  return  vec4f(gridPos, 0, 1);
}
  • מבטאים את אותו הדבר באמצעות מבני לקלט ולפלט של הפונקציה:

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

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

struct VertexOutput {
  @builtin(position) pos: vec4f,
};

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

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  return output;
}

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

העברת נתונים בין פונקציות הנקודה והשברים

תזכורת: הפונקציה @fragment פשוטה ככל האפשר:

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

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1);
}

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

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

  1. משנים את ערך ההחזרה של שפת שגיאת הקודקודים (vertex shader), כך:

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

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

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

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

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

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

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

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

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

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

קריאה ל-createShaderModule

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

התוצאה נראית טוב!

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

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

7. ניהול מצב התא

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

כל מה שצריך הוא אות הפעלה/כיבוי לכל תא, כך שכל אפשרות שמאפשרת לאחסן מערך גדול של כמעט כל סוג ערך מתאימה. יכול להיות שתחשבו שזה תרחיש נוסף לדוגמה של חוצצים אחידים! למרות שיכול להיות שזה יעבוד, זה קשה יותר כי הגודל של מאגרי נתונים זמניים אחידים מוגבל, לא יכול לתמוך במערכים בגודל דינמי (צריך לציין את גודל המערך בכלי ההצללה) ולא ניתן לכתוב להם על ידי מחשוב. הפריט האחרון הוא הבעיה העיקרית, כי אתם רוצים לבצע את הסימולציה של Game of Life ב-GPU באמצעות שדה חישוב (compute shader).

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

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

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

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

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

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!

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

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

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

שימוש בתבנית מאגר הנתונים הזמני של פינג-פונג

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

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

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

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

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

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

8. הפעלת הסימולציה

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

סוף סוף אפשר להשתמש בתוכנות הצללה למחשוב!

במהלך ה-Codelab הזה למדת בצורה מופשטת על תוכנות הצללה למחשוב, אבל מהם בעצם?

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

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

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

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

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

}

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

אפשר להשתמש גם ב-uniforms ב-compute shaders, בדיוק כמו ב-vertex shaders וב-fragment shaders.

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

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

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

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

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

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

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

}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

index.html

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

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

בכל רשומה, מציינים את המספר binding של המשאב, שתואם לערך @binding שבשגיאות (כפי שלמדתם כשיצרתם את קבוצת הקישור). צריך גם לספק את visibility, שהם דגלים של GPUShaderStage שמציינים אילו שלבי צללים יכולים להשתמש במשאב. צריך שאפשר יהיה לגשת גם למאגר האחסון של המשתנים הקבועים וגם למאגר האחסון הראשון ב-vertex shader וב-compute shader, אבל צריך שאפשר יהיה לגשת למאגר האחסון השני רק ב-compute shader.

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

במילון המאגר, אפשר להגדיר אפשרויות כמו type של מאגר שבו נעשה שימוש. ברירת המחדל היא "uniform", כך שאפשר להשאיר את המילון ריק לקישור 0. (עם זאת, צריך להגדיר לפחות את buffer: {}, כדי שהערך יזוהה כמאגר נתונים זמני). למאפיין הקישור 1 מוקצה הסוג "read-only-storage" כי לא משתמשים בו עם הרשאת גישה read_write בשפת השיזוע, ולמאפיין הקישור 2 מוקצה הסוג "storage" כי כן משתמשים בו עם הרשאת גישה read_write!

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

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

index.html

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

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

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

  1. יוצרים GPUPipelineLayout.

index.html

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

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

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

index.html

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

יצירת צינור עיבוד הנתונים לחישוב

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

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

index.html

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

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

כרטיסים ל-Compute

עכשיו מגיעים לשלב שבו משתמשים בצינור עיבוד הנתונים לעיבוד. מאחר שאתם מבצעים את העיבוד ב-render pass, סביר להניח שאתם מבצעים את עיבוד הנתונים ב-compute pass. עיבוד ועיבוד גרפי יכולים להתרחש באותו מקודד פקודות, לכן כדאי לשנות מעט את הפונקציה 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. לבסוף, במקום לצייר כמו בתהליך רינדור, שולחים את העבודה ל-compute shader ומציינים לו כמה קבוצות עבודה רוצים להריץ בכל ציר.

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().

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

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

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

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

  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 (קריאה ל-createShaderModule של Compute)

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

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

  1. מחפשים את מספר השכנים הפעילים, כך:

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

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

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

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

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

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

לאחר מכן מחילים אחד מארבע הכללים הבאים:

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

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

  1. הטמיעו את הלוגיקה של משחק החיים, באופן הבא:

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

let i = cellIndex(cell.xy);

// Conway's game of life rules:
switch activeNeighbors {
  case 2: { // Active cells with 2 neighbors stay active.
    cellStateOut[i] = cellStateIn[i];
  }
  case 3: { // Cells with 3 neighbors become or stay active.
    cellStateOut[i] = 1;
  }
  default: { // Cells with < 2 or > 3 neighbors become inactive.
    cellStateOut[i] = 0;
  }
}

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

index.html

const simulationShaderModule = device.createShaderModule({
  label: "Life simulation shader",
  code: `
    @group(0) @binding(0) var<uniform> grid: vec2f;

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

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

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

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

      let i = cellIndex(cell.xy);

      // Conway's game of life rules:
      switch activeNeighbors {
        case 2: {
          cellStateOut[i] = cellStateIn[i];
        }
        case 3: {
          cellStateOut[i] = 1;
        }
        default: {
          cellStateOut[i] = 0;
        }
      }
    }
  `
});

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

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

9. מעולה!

יצרתם גרסה של הסימולציה הקלאסית של Game of Life של Conway, שפועלת לגמרי ב-GPU באמצעות WebGPU API.

מה השלב הבא?

קריאה נוספת

מסמכי עזר