ציור על בד ציור ב-emscripten

איך מבצעים רינדור של גרפיקה 2D באינטרנט מ-WebAssembly באמצעות Emscripten

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

בפוסט הזה תלמדו כמה שיטות לציור גרפיקה דו-ממדית (2D) ברכיב הקנבס באינטרנט מקוד C או C++ שעבר הידור באמצעות Emscripten.

אם אתם מתחילים פרויקט חדש במקום לנסות להעביר פרויקט קיים, ייתכן שיהיה הכי קל להשתמש ב-Canvas API של HTML דרך מערכת הקישור של Emscripten, Embind. Embind מאפשר לפעול ישירות על ערכים שרירותיים של JavaScript.

כדי להבין איך משתמשים ב-Embind, כדאי קודם לעיין בדוגמה הבאה מ-MDN שמוצאת רכיב <canvas> ומציירת עליו כמה צורות.

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

כך אפשר לתרגם את הקוד ל-C++ באמצעות Embind:

#include <emscripten/val.h>

using emscripten::val;

// Use thread_local when you want to retrieve & cache a global JS variable once per thread.
thread_local const val document = val::global("document");

// …

int main() {
  val canvas = document.call<val>("getElementById", "canvas");
  val ctx = canvas.call<val>("getContext", "2d");
  ctx.set("fillStyle", "green");
  ctx.call<void>("fillRect", 10, 10, 150, 100);
}

כשמקשרים את הקוד הזה, חשוב להעביר את הערך --bind כדי להפעיל את Embind:

emcc --bind example.cpp -o example.html

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

דף HTML שנוצר על ידי Emscripten שבו מוצג מלבן ירוק על קנבס שחור.

בחירת רכיב הקנבס

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

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

אם משתמשים במצב ES6 (הגדרת הפלט לנתיב עם סיומת .mjs או שימוש בהגדרה -s EXPORT_ES6), אפשר להעביר את הלוח כך:

import initModule from './emscripten-generated.mjs';

const Module = await initModule({
  canvas: document.getElementById('my-canvas')
});

אם אתם משתמשים בפלט רגיל של סקריפט, צריך להצהיר על האובייקט Module לפני טעינת קובץ ה-JavaScript שנוצר על ידי Emscripten:

<script>
var Module = {
  canvas: document.getElementById('my-canvas')
};
</script>
<script src="emscripten-generated.js"></script>

OpenGL ו-SDL2

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

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

ציור מלבן

בקטע 'מידע על SDL' באתר הרשמי כתוב:

Simple DirectMedia Layer היא ספריית פיתוח בפלטפורמות שונות, שנועדה לספק גישה ברמה נמוכה לחומרה של אודיו, מקלדת, עכבר, ג'ויסטיק וגרפיקה באמצעות OpenGL ו-Direct3D.

כל התכונות האלה – שליטה באודיו, במקלדת, בעכבר ובגרפיקה – הועברו ופועלות גם עם Emscripten באינטרנט, כך שתוכלו להעביר משחקים שלמים שנוצרו באמצעות SDL2 בלי הרבה טרחה. אם אתם מעבירים פרויקט קיים, כדאי לעיין בקטע Integrating with a build system במסמכי Emscripten.

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

#include <SDL2/SDL.h>

int main() {
  // Initialize SDL graphics subsystem.
  SDL_Init(SDL_INIT_VIDEO);

  // Initialize a 300x300 window and a renderer.
  SDL_Window *window;
  SDL_Renderer *renderer;
  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  // Set a color for drawing matching the earlier `ctx.fillStyle = "green"`.
  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  // Create and draw a rectangle like in the earlier `ctx.fillRect()`.
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);

  // Render everything from a buffer to the actual screen.
  SDL_RenderPresent(renderer);

  // TODO: cleanup
}

כשמקשרים עם Emscripten, צריך להשתמש ב--s USE_SDL=2. הפקודה הזו תורה ל-Emscripten לאחזר את ספריית SDL2, שכבר נוצרה מראש ל-WebAssembly, ולקשר אותה לאפליקציה הראשית.

emcc example.cpp -o example.html -s USE_SDL=2

כשהדוגמה נטענת בדפדפן, מופיע המלבן הירוק המוכר:

דף HTML שנוצר על ידי Emscripten, שבו מוצג מלבן ירוק על קנבס מרובע שחור.

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

clang example.cpp -o example -lSDL2

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

שילוב של לולאת אירועים

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

#include <SDL2/SDL.h>

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_Window *window;
  SDL_Renderer *renderer;
  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);

  while (1) {
    SDL_Event event;
    SDL_PollEvent(&event);
    if (event.type == SDL_QUIT) {
      break;
    }
  }

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

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

עכשיו, הידור הדוגמה הזו ב-Linux פועל כצפוי ומוצג חלון בגודל 300 על 300 עם מלבן ירוק:

חלון Linux מרובע עם רקע שחור ומלבן ירוק.

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

דף HTML שנוצר על ידי Emscripten עם חלון דו-שיח של השגיאה &#39;הדף לא מגיב&#39;, עם הצעה לחכות עד שהדף יגיב או לצאת מהדף

מה קרה? אצטט את התשובה מהמאמר "שימוש בממשקי API אסינכרוניים של אינטרנט מ-WebAssembly":

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

חשוב לזכור לגבי המנגנון הזה: בזמן שהקוד בהתאמה אישית של JavaScript (או WebAssembly) פועל, לולאת האירועים חסומה […]

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

יש שתי דרכים לפתור את הבעיה הזו.

ביטול החסימה של לולאת האירועים באמצעות Asyncify

קודם כול, כפי שמתואר במאמר המקושר, אפשר להשתמש ב-Asyncify. זוהי תכונה של Emscripten שמאפשרת "להשהות" את תוכנית ה-C או ה-C++‎, להחזיר את השליטה ל-event loop ולהעיר את התוכנית כשפעולה אסינכרונית כלשהי מסתיימת.

פעולה אסינכרונית כזו יכולה להיות גם 'השהיה למשך הזמן המינימלי האפשרי', שמתבטאת דרך ה-API של emscripten_sleep(0). הטמעה באמצע הלולאה מאפשרת לי לוודא שהשליטה תוחזר ללולאת האירועים של הדפדפן בכל חזרה, והדף יישאר תגובה ויכול לטפל בכל אירוע:

#include <SDL2/SDL.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_Window *window;
  SDL_Renderer *renderer;
  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);

  while (1) {
    SDL_Event event;
    SDL_PollEvent(&event);
    if (event.type == SDL_QUIT) {
      break;
    }
#ifdef __EMSCRIPTEN__
    emscripten_sleep(0);
#endif
  }

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

עכשיו צריך לקמפל את הקוד הזה עם הפעלת Asyncify:

emcc example.cpp -o example.html -s USE_SDL=2 -s ASYNCIFY

והאפליקציה שוב פועלת כצפוי באינטרנט:

דף HTML שנוצר על ידי Emscripten, שבו מוצג מלבן ירוק על קנבס מרובע שחור.

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

ביטול החסימה של לולאת האירועים באמצעות ממשקי API של 'לולאת ראשית'

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

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

emscripten_set_main_loop(callback, 0, true);

ל-callback החדש לא תהיה גישה למשתני ה-stack בפונקציה main, לכן צריך לחלץ משתנים כמו window ו-renderer למבנה (struct) שהוקצה ב-heap ולהעביר את הפונקציה שלו באמצעות הווריאנט emscripten_set_main_loop_arg של ה-API, או לחלץ אותם למשתני static גלובליים (בחרתי באפשרות השנייה כדי לפשט את הקוד). קשה יותר לעקוב אחרי התוצאה, אבל היא יוצרת את אותו מלבן כמו בדוגמה האחרונה:

#include <SDL2/SDL.h>
#include <stdio.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

SDL_Window *window;
SDL_Renderer *renderer;

bool handle_events() {
  SDL_Event event;
  SDL_PollEvent(&event);
  if (event.type == SDL_QUIT) {
    return false;
  }
  return true;
}

void run_main_loop() {
#ifdef __EMSCRIPTEN__
  emscripten_set_main_loop([]() { handle_events(); }, 0, true);
#else
  while (handle_events())
    ;
#endif
}

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);

  run_main_loop();

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

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

emcc example.cpp -o example.html -s USE_SDL=2

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

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

טיפול באינטראקציות של משתמשים

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

#include <SDL2/SDL.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

SDL_Window *window;
SDL_Renderer *renderer;

SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};

void redraw() {
  SDL_SetRenderDrawColor(renderer, /* RGBA: black */ 0x00, 0x00, 0x00, 0xFF);
  SDL_RenderClear(renderer);
  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);
}

uint32_t ticksForNextKeyDown = 0;

bool handle_events() {
  SDL_Event event;
  SDL_PollEvent(&event);
  if (event.type == SDL_QUIT) {
    return false;
  }
  if (event.type == SDL_KEYDOWN) {
    uint32_t ticksNow = SDL_GetTicks();
    if (SDL_TICKS_PASSED(ticksNow, ticksForNextKeyDown)) {
      // Throttle keydown events for 10ms.
      ticksForNextKeyDown = ticksNow + 10;
      switch (event.key.keysym.sym) {
        case SDLK_UP:
          rect.y -= 1;
          break;
        case SDLK_DOWN:
          rect.y += 1;
          break;
        case SDLK_RIGHT:
          rect.x += 1;
          break;
        case SDLK_LEFT:
          rect.x -= 1;
          break;
      }
      redraw();
    }
  }
  return true;
}

void run_main_loop() {
#ifdef __EMSCRIPTEN__
  emscripten_set_main_loop([]() { handle_events(); }, 0, true);
#else
  while (handle_events())
    ;
#endif
}

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  redraw();
  run_main_loop();

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

ציור צורות אחרות באמצעות SDL2_gfx

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

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

#include <SDL2/SDL.h>
#include <SDL2/SDL2_gfxPrimitives.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

SDL_Window *window;
SDL_Renderer *renderer;

SDL_Point center = {.x = 100, .y = 100};
const int radius = 100;

void redraw() {
  SDL_SetRenderDrawColor(renderer, /* RGBA: black */ 0x00, 0x00, 0x00, 0xFF);
  SDL_RenderClear(renderer);
  filledCircleRGBA(renderer, center.x, center.y, radius,
                   /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_RenderPresent(renderer);
}

uint32_t ticksForNextKeyDown = 0;

bool handle_events() {
  SDL_Event event;
  SDL_PollEvent(&event);
  if (event.type == SDL_QUIT) {
    return false;
  }
  if (event.type == SDL_KEYDOWN) {
    uint32_t ticksNow = SDL_GetTicks();
    if (SDL_TICKS_PASSED(ticksNow, ticksForNextKeyDown)) {
      // Throttle keydown events for 10ms.
      ticksForNextKeyDown = ticksNow + 10;
      switch (event.key.keysym.sym) {
        case SDLK_UP:
          center.y -= 1;
          break;
        case SDLK_DOWN:
          center.y += 1;
          break;
        case SDLK_RIGHT:
          center.x += 1;
          break;
        case SDLK_LEFT:
          center.x -= 1;
          break;
      }
      redraw();
    }
  }
  return true;
}

void run_main_loop() {
#ifdef __EMSCRIPTEN__
  emscripten_set_main_loop([]() { handle_events(); }, 0, true);
#else
  while (handle_events())
    ;
#endif
}

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  redraw();
  run_main_loop();

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

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

# Native version
$ clang example.cpp -o example -lSDL2 -lSDL2_gfx
# Web version
$ emcc --bind foo.cpp -o foo.html -s USE_SDL=2 -s USE_SDL_GFX=2

אלה התוצאות שפועלות ב-Linux:

חלון Linux מרובע עם רקע שחור ומעגל ירוק.

ובאינטרנט:

דף HTML שנוצר על ידי Emscripten שבו מוצג עיגול ירוק על קנבס מרובע שחור.

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