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

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

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

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

קנבס דרך 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 שנוצר על ידי כתב יד שבו מלבן ירוק על קנבס מרובע שחור.

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

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":

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

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

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

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

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

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

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

עם זאת, בקובץ 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 – לא נלקח באינטרנט.

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

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