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

1. מבוא

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

מה זה WebGPU?

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

Modern API

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

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

Rendering

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

Compute

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

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

מה תפַתחו

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

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

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

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

מה תלמדו

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

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

מה צריך להכין

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

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

2. להגדרה

קבלת הקוד

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

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

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

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

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

3. אתחול WebGPU

מתחילים עם <canvas>

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

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

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

index.html

<!doctype html>

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

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

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

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

  1. כדי לבדוק אם האובייקט navigator.gpu, שמשמש כנקודת הכניסה ל-WebGPU, קיים, מוסיפים את הקוד הבא:

index.html

if (!navigator.gpu) {
  throw new Error("WebGPU not supported on this browser.");
}

מומלץ להודיע למשתמש אם WebGPU לא זמין, על ידי הגדרת הדף למצב שלא משתמש ב-WebGPU. (אולי אפשר להשתמש ב-WebGL במקום?) אבל לצורך ה-codelab הזה, פשוט צריך להציג שגיאה כדי שהקוד לא ימשיך לפעול.

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

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

ניקוי ה-Canvas

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

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

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

index.html

const encoder = device.createCommandEncoder();

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

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

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

index.html

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

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

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

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

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

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

index.html

pass.end();

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

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

index.html

const commandBuffer = encoder.finish();
  1. שולחים את מאגר הפקודות ל-GPU באמצעות queue של GPUDevice. התור מבצע את כל הפקודות של ה-GPU, וכך מוודא שהביצוע שלהן מסודר ומסונכרן בצורה נכונה. השיטה submit() של התור מקבלת מערך של מאגרי פקודות, אבל במקרה הזה יש רק אחד.

index.html

device.queue.submit([commandBuffer]);

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

index.html

// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);

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

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

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

בוחרים צבע!

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

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

index.html

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

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

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

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

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

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

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

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

הסבר על אופן השרטוט של מעבדי GPU

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

index.html

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

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

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

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

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

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

index.html

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

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

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

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

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

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

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

index.html

const vertexBuffer = device.createBuffer({
  label: "Cell vertices",
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

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

לאחר מכן, מציינים את הגודל של המאגר בבייטים. צריך מאגר נתונים זמני עם 48 בייטים, שנקבע על ידי הכפלת הגודל של מספר נקודה צפה (float) בן 32 ביט (4 בייטים) במספר המספרים הצפים במערך vertices (12). למזלנו, TypedArrays כבר מחשב את byteLength בשבילכם, ולכן אתם יכולים להשתמש בו כשאתם יוצרים את ה-buffer.

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

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

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

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

index.html

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

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

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

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

index.html

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

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

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

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

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

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

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

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

איך מתחילים לעבוד עם shaders

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

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

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

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

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

index.html

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

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

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

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

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

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

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

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

index.html (createShaderModule code)

@vertex
fn vertexMain() {

}

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

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

index.html (קוד createShaderModule)

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

}

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

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

index.html (קוד createShaderModule)

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

במקום זאת, אתם רוצים להשתמש בנתונים מהמאגר שיצרתם. כדי לעשות את זה, אתם צריכים להצהיר על ארגומנט לפונקציה עם מאפיין @location() וסוג שתואמים למה שתיארתם ב-vertexBufferLayout. ציינתם shaderLocation של 0, לכן בקוד ה-WGSL צריך לסמן את הארגומנט באמצעות @location(0). הגדרתם גם את הפורמט כ-float32x2, שהוא וקטור דו-ממדי, ולכן ב-WGSL הארגומנט הוא vec2f. אתם יכולים לתת לו כל שם שתרצו, אבל מכיוון שהוא מייצג את מיקומי הקודקודים, שם כמו pos נראה טבעי.

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

index.html (קוד createShaderModule)

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

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

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

index.html (קוד createShaderModule)

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

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

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

index.html (קוד createShaderModule)

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

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

הגדרת הצללה של מקטעים

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

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

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

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

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

index.html (קוד createShaderModule)

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

}

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

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

index.html (קוד createShaderModule)

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

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

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

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

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

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

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

שרטוט הריבוע

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

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

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

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

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

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

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

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

5. ציור רשת

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

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

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

הגדרת הרשת

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

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

index.html

const GRID_SIZE = 4;

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

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

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

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

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

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

index.html

// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
  label: "Grid Uniforms",
  size: uniformArray.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);

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

גישה למשתנים אחידים ב-shader

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

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

// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;

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

// ...fragmentMain is unchanged 

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

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

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

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

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

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

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

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

index.html

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

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

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

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

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

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

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

  1. חוזרים אל render pass ומוסיפים את השורה החדשה הזו לפני השיטה draw():

index.html

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);

pass.setBindGroup(0, bindGroup); // New line!

pass.draw(vertices.length / 2);

ה-0 שמועבר כארגומנט הראשון תואם ל-@group(0) בקוד של ה-Shader. המשמעות היא שכל @binding שהוא חלק מ-@group(0) משתמש במשאבים בקבוצת ההתאמה הזו.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

המחשה של אזור הציור שמחולק באופן קונספטואלי לרשת של 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 בשיידר ולאכלס אותו בערך סטטי כמו 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, בנוסף למאפייני הקודקוד כמו pos שמגיעים ממאגר הקודקודים, אפשר גם לגשת למה שמכונה ערכים מובנים של WGSL. אלה ערכים שמחושבים על ידי WebGPU, ואחד מהם הוא instance_index. ‫instance_index הוא מספר לא חתום בן 32 ביט, מ-0 עד number of instances - 1, שאפשר להשתמש בו כחלק מהלוגיקה של ה-Shader. הערך שלו זהה לכל קודקוד שעובר עיבוד והוא חלק מאותו מופע. כלומר, הפונקציה vertex shader נקראת שש פעמים עם instance_index של 0, פעם אחת לכל מיקום במאגר הנתונים של הקודקודים. אחר כך עוד 6 פעמים עם instance_index של 1, אחר כך עוד 6 פעמים עם instance_index של 2 וכן הלאה.

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

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

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

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

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

הדרך היחידה להעביר נתונים מחוץ ל-vertex shader היא להחזיר אותם. תמיד צריך להחזיר מיקום באמצעות Vertex Shader, ולכן אם רוצים להחזיר נתונים אחרים יחד עם המיקום, צריך להציב אותם במבנה. מבנים ב-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);
}
  • מבטאים את אותו הדבר באמצעות מבני נתונים (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 כדי להתייחס למיקום הקלט ולאינדקס המופע, וצריך להגדיר את ה-struct שמוחזר כמשתנה ולהגדיר את המאפיינים הנפרדים שלו. במקרה הזה, אין הבדל משמעותי, ולמעשה הפונקציה של ה-shader קצת יותר ארוכה, אבל ככל שה-shaders יהיו מורכבים יותר, השימוש במבני נתונים יכול לעזור לכם לארגן את הנתונים.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

הפעלת createShaderModule

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

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

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

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

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

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

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

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

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

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

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

index.html

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

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

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

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

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

index.html

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

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

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

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

index.html

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

קודם מוסיפים את נקודת הקישור, שנמצאת ממש מתחת ל-grid uniform. אתם רוצים להשאיר את אותו @group כמו בgrid האחיד, אבל מספר @binding צריך להיות שונה. הסוג של var הוא storage, כדי לשקף את סוג המאגר השונה, ובמקום וקטור יחיד, הסוג שנותנים ל-cellState הוא מערך של ערכי u32, כדי להתאים ל-Uint32Array ב-JavaScript.

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

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

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

index.html

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> VertexOutput {
  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let state = f32(cellState[instance]); // New line!

  let cellOffset = cell / grid * 2;
  // New: Scale the position by the cell's active state.
  let gridPos = (pos*state+1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell;
  return output;
}

הוספת מאגר האחסון לקבוצת האיגוד

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

  • מוסיפים את נפח האחסון הנוסף, כך:

index.html

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

מוודאים שערך binding של הרשומה החדשה תואם לערך @binding() של הערך המתאים בשיידר.

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

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

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

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

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

// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];

function simulate() {
  for (let i = 0; i < state.length-1; ++i) {
    if (state[i] == 1) {
      state[i] = 0;
      state[i+1] = 1;
    }
  }
}

simulate(); // Run the simulation for one step.

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

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

// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];

function simulate(inArray, outArray) {
  outArray[0] = 0;
  for (let i = 1; i < inArray.length; ++i) {
     outArray[i] = inArray[i-1];
  }
}

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA); 
  1. כדי להשתמש בתבנית הזו בקוד שלכם, צריך לעדכן את הקצאת מאגר האחסון כדי ליצור שני מאגרים זהים:

index.html

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

index.html

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

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

index.html

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

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

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

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

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

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

index.html

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

index.html

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

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

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

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

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

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

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

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

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

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

סוף סוף אפשר להשתמש ב-compute shaders!

במהלך ה-codelab הזה למדתם באופן תיאורטי על shaders של מחשוב, אבל מה הם בדיוק?

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

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

  1. יוצרים shader לחישוב עם הקוד הבא:

index.html

// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
  label: "Game of Life simulation shader",
  code: `
    @compute
    fn computeMain() {

    }`
});

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

בגלל האופי של ארכיטקטורת חומרת ה-GPU, הרשת הזו מחולקת לקבוצות עבודה. לקבוצת עבודה יש גודל X,‏ Y ו-Z, ולמרות שהגודל יכול להיות 1 בכל אחד מהמקרים, לרוב יש יתרונות בביצועים אם קבוצות העבודה גדולות יותר. בשיידר, בוחרים גודל קבוצת עבודה שרירותי כלשהו של 8 כפול 8. כדאי לעקוב אחרי זה בקוד JavaScript.

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

index.html

const WORKGROUP_SIZE = 8;

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

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

index.html (Compute createShaderModule call)

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

}

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

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

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

index.html (Compute createShaderModule call)

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

}

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

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

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

index.html (Compute createShaderModule call)

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

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

}

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

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

index.html (Compute createShaderModule call)

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

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

}

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

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

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

index.html (Compute createShaderModule call)

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

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

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

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

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

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

index.html (Compute createShaderModule call)

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

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

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

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

שימוש בפריסות של קבוצות קשירה וצינורות

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

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

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

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

index.html

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

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

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

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

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

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

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

index.html

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

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

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

  1. ליצור GPUPipelineLayout.

index.html

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

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

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

index.html

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

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

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

  • יוצרים צינור עיבוד נתונים עם הקוד הבא:

index.html

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

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

כרטיסים לחישוב

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

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

index.html

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

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

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

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

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

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

index.html

const computePass = encoder.beginComputePass();

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

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

index.html

const computePass = encoder.beginComputePass();

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

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

computePass.end();

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

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

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

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

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

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

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

index.html

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

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

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

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

index.html (Compute createShaderModule call)

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

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

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

index.html (Compute createShaderModule call)

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

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

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

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

index.html (Compute createShaderModule call)

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

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

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

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

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

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

index.html (Compute createShaderModule call)

let i = cellIndex(cell.xy);

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

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

index.html

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

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

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

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

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

      let i = cellIndex(cell.xy);

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

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

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

9. מעולה!

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

מה השלב הבא?

קריאה נוספת

מאמרי עזרה