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

האפליקציה הראשונה שלכם עם WebGPU

מידע על Codelab זה

subjectהעדכון האחרון: אפר׳ 15, 2025
account_circleנכתב על ידי Brandon Jones, François Beaufort

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 כדי לשנות את מה שמצויר.
  • איך משתמשים ב-compute shaders כדי לבצע סימולציה פשוטה.

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

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

ניקוי לוח הציור

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

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

  1. לשם כך, צריך לגרום למכשיר ליצור GPUCommandEncoder, שמספק ממשק להקלטת פקודות GPU.

index.html

const encoder = device.createCommandEncoder();

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

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

  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 } הוא ברירת המחדל, שחור שקוף.

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

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

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

4.‏ ציור גיאומטריה

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

הסבר על אופן היצירה של גרפיקה על ידי מעבדי GPU

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

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

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

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

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

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

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

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

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

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

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

יצירת מאגר קודקודים

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

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

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

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

תחילת העבודה עם שיבושים (shaders)

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

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

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

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

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

index.html (קוד createShaderModule)

@vertex
fn vertexMain() {

}

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

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

index.html (קוד createShaderModule)

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

}

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

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

index.html (קוד createShaderModule)

@vertex
fn vertexMain() -> @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}

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

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

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

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

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

שרטוט הריבוע

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

  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 כדי לשנות את התנהגות הרינדור.
  • איך משתמשים ביצירת מכונות (instancing) כדי לצייר גרסאות רבות ושונות של אותה גיאומטריה.

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

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

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

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

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

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

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

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

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

ציור מכונות

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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() בערך התואם ב-shader.

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

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

שימוש בתבנית מאגר נתונים זמני מסוג ping-pong

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

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

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

שימוש בתבנית ping pong מבטיח שתמיד תבצעו את השלב הבא בסימולציה באמצעות התוצאות של השלב האחרון בלבד.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

index.html

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

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

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

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

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

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

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.

מה השלב הבא?

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

מסמכי עזר