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

1. מבוא

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

מה זה WebGPU?

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

API מודרני

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

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

רינדור

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

Compute

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

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

מה תפַתחו

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

שימוש במסוף הפיתוח

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

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

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

index.html

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("No appropriate GPUAdapter found.");
}

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

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

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

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

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

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

קנבס שנמחק לצבע כחול כהה כדי להדגים איך לשנות את צבע ברירת המחדל של &#39;שקוף&#39;.

4. ציור צורות גאומטריות

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

הסבר על אופן הרישום של GPU

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

תחילת העבודה עם צללים

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

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

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

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

  • יוצרים מקום להזנת קוד ה-shader על ידי העתקת הקוד הבא לקוד שלכם מתחת ל-vertexBufferLayout:

index.html

const cellShaderModule = device.createShaderModule({
  label: "Cell shader",
  code: `
    // Your shader code will go here
  `
});

כדי ליצור את ה-shaders, קוראים לפונקציה device.createShaderModule() ומספקים לה label ו-WGSL אופציונליים code כמחרוזת. (שימו לב: צריך להשתמש בקו נטוי לאחור כדי לאפשר מחרוזות בכמה שורות!) אחרי שמוסיפים קוד WGSL תקין, הפונקציה מחזירה אובייקט GPUShaderModule עם התוצאות שעברן הידור.

הגדרת Vertex Shader

כדאי להתחיל עם שדה הצבעים של הנקודה (vertex shader), כי גם ה-GPU מתחיל שם.

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

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

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

  1. יוצרים פונקציית @vertex ריקה, כך:

index.html (קוד createShaderModule)

@vertex
fn vertexMain() {

}

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

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);
}

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

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

הגדרת ה-fragment shader

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

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

בדומה ל-vertex shaders, גם fragment 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)
}

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

לסיכום, אחרי שמוסיפים את קוד ה-shader שמפורט למעלה, הקריאה ל-createShaderModule נראית עכשיו כך:

index.html

const cellShaderModule = device.createShaderModule({
  label: 'Cell shader',
  code: `
    @vertex
    fn vertexMain(@location(0) pos: vec2f) ->
      @builtin(position) vec4f {
      return vec4f(pos, 0, 1);
    }

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

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

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

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

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

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

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

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

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

5. ציור רשת

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

בחלק הזה נסביר:

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

הגדרת התצוגה של הרשת

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

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

index.html

const GRID_SIZE = 4;

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

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

יצירת מאגר אחיד

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

גישה למשתני אחידים (uniforms) בשיידר

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

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 

כך מגדירים משתנה אחיד (uniform) בשדר (shader) שנקרא grid. זהו וקטור של 2D float שתואם למערך שהעתקתם למאגר המשתנים האחידים. בנוסף, מצוין שהעמודה מקושרת ב-@group(0) וב-@binding(0). בקרוב נסביר מה המשמעות של הערכים האלה.

לאחר מכן, במקום אחר בקוד של תוכנת ההצללה, תוכלו להשתמש בווקטור הרשת בכל דרך שתרצו. בקוד הזה מחלקים את מיקום הנקודה בווקטור של הרשת. מאחר ש-pos הוא וקטור דו-מימדי ו-grid הוא וקטור דו-מימדי, WGSL מבצע חלוקה לפי רכיבים. במילים אחרות, התוצאה זהה לאמירת vec2f(pos.x / grid.x, pos.y / grid.y).

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

במקרה שלך, המשמעות היא (אם השתמשת בגודל רשת של 4) שהריבוע שתייצר יהיה רבע מהגודל המקורי שלו. זה מושלם אם רוצים להציב ארבעה מהם בשורה או בעמודה.

יצירת קבוצת Bind

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

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

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

index.html

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

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

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

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

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

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

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

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

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

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

מניפולציה של גיאומטריה בכלי ההצללה

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

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

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

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

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

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

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

ציור מופעים

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

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

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

שימוש במבנים ב-shaders

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

שימוש ב-compute shaders, סוף סוף!

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

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

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

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

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

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

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

}

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

אפשר להשתמש גם ב-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 (קריאה ל-createShaderModule של Compute)

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

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

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

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

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

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

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

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

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

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

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

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

index.html

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

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

כרטיסי Compute

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

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

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)

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. מטמיעים את הלוגיקה של משחק החיים, כך:

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

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. מעולה!

יצרת גרסה של הסימולציה הקלאסית של משחק החיים של Conway שפועלת ב-GPU כולו באמצעות WebGPU API!

מה השלב הבא?

מקורות מידע נוספים

מסמכי עזר