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

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

כשמשתמשים במעטפת ה-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 ו-SSDL2

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

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

ציור מלבן

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

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

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

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

#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 שנוצר באמצעות emscript, שמוצג בו מלבן ירוק על קנבס מרובע שחור.

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

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

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

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

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

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

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

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

#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 שנוצר באמצעות emscript, שמוצג בו מלבן ירוק על קנבס מרובע שחור.

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

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

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

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

# 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 שנוצר באמצעות emscript, שמוצג בו עיגול ירוק על קנבס מרובע שחור.

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