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

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

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

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

Canvas דרך Embind

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

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

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

ציור מלבן

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

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

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

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

emscripten_set_main_loop(callback, 0, true);

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

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