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

1. מבוא

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

עדכון אחרון: 28.08.2023

מה זה WebGPU?

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

API מודרני

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

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

רינדור

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

Compute

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

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

מה תפַתחו

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

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

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

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

מה תלמדו

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

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

מה צריך להכין

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

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

2. להגדרה

לקבלת הקוד

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

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

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

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

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

3. אתחול WebGPU

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

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

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

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

הגדרת אזור העריכה

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

  • כדי לעשות זאת, קודם צריך לבקש GPUCanvasContext מאזור העריכה על ידי קריאה אל canvas.getContext("webgpu"). (זו אותה הקריאה שבה השתמשת כדי לאתחל הקשרי Canvas 2D או WebGL, באמצעות סוגי ההקשר 2d ו-webgl, בהתאמה). לאחר מכן, הערך של context שהוא מחזיר צריך להיות משויך למכשיר באמצעות השיטה configure(), כך:

index.html

const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device: device,
  format: canvasFormat,
});

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

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

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

ניקוי הקנבס

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

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

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

index.html

const encoder = device.createCommandEncoder();

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

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

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

index.html

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

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

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

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

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

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

index.html

pass.end();

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

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

index.html

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

index.html

device.queue.submit([commandBuffer]);

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

index.html

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

index.html

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

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

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

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

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

  1. צריך לעדכן את המערך הקודם של vertices כך שייראה כך:

index.html

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

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

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

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

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

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

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

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

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

index.html

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

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

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

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

index.html

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

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

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

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

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

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

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

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

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

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

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

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

תוכנות ההצללה עצמן מועברות אל WebGPU כמחרוזות.

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

index.html

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

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

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

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

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

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

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

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

index.html (קוד createShaderModule)

@vertex
fn vertexMain() {

}

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

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

index.html (קוד createShaderModule)

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

}

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

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

index.html (קוד createShaderModule)

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

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

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

index.html (קוד createShaderModule)

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

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

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

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

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

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

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

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

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

index.html (קוד createShaderModule)

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

}

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

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

index.html (קוד createShaderModule)

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

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

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

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

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

משרטטים את הריבוע

וזהו, עכשיו יש לך את כל מה שצריך כדי לשרטט את הריבוע!

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

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

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

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

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

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

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

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

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

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

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

הגדרת הרשת

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

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

index.html

const GRID_SIZE = 4;

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

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

גישה למדים בכלי להצללה

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

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). מיד תוכלו להבין מה המשמעות של הערכים האלה.

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

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

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

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

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

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

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

index.html

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

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

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

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

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

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

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

  return vec4f(gridPos, 0, 1); 
}

ועכשיו הריבוע שלך ממוקם טוב בתא (0, 0)!

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

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

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

  1. משנים את מיקום הרשת, באופן הבא:

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

אם יתבצע רענון עכשיו, יוצגו הפרטים הבאים:

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

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

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

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

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

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

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

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

ציור מופעים

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

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

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

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

index.html

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

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

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

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

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

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

  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);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

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

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

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

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

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

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

index.html

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

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

index.html

const GRID_SIZE = 32;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

קריאה ל-createShaderModule

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

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

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

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

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

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

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

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

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

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

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

index.html

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

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

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

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

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

index.html

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

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

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

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

index.html

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

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

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

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

  1. מעדכנים את קוד ההצללה כדי לשנות את גודל המיקום לפי מצב הפעילות של התא. כדי לעמוד בדרישות הבטיחות של 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(in, out) {
  out[0] = 0;
  for (let i = 1; i < in.length; ++i) {
     out[i] = in[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);

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

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

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

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

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

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

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

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

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

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

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

index.html

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

    }`
});

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

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

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

index.html

const WORKGROUP_SIZE = 8;

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

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

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

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

}

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

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

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

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

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

}

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

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

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

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

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

}

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

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

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

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

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

}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

שימוש ב-Bid Group ובפריסות צינור עיבוד נתונים

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

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

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

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

index.html

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

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

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

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

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

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

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

index.html

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

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

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

  1. יוצרים GPUPipelineLayout.

index.html

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

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

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

index.html

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

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

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

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

index.html

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

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

כרטיסים ל-Compute

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

let i = cellIndex(cell.xy);

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

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

index.html

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

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

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

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

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

      let i = cellIndex(cell.xy);

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

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

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

9. מעולה!

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

מה השלב הבא?

קריאה נוספת

מסמכי עזר