איך מבצעים רינדור של גרפיקה 2D באינטרנט מ-WebAssembly באמצעות Emscripten
למערכות הפעלה שונות יש ממשקי API שונים לציור גרפיקה. ההבדלים הופכים למבלבלים עוד יותר כשכותבים קוד בפלטפורמות שונות, או מעבירים גרפיקה ממערכת אחת לאחרת, כולל העברה של קוד מקורי ל-WebAssembly.
בפוסט הזה תלמדו כמה שיטות לציור גרפיקה דו-ממדית (2D) ברכיב הקנבס באינטרנט מקוד C או C++ שעבר הידור באמצעות Emscripten.
הדפסה על קנבס דרך Embind
אם אתם מתחילים פרויקט חדש במקום לנסות להעביר פרויקט קיים, ייתכן שיהיה הכי קל להשתמש ב-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 עם פקודת המעטפת הקודמת, לוח הציור נכלל ומוגדר בשבילכם. כך קל יותר ליצור הדגמות ודוגמאות פשוטות, אבל באפליקציות גדולות יותר כדאי לכלול את 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
כשהדוגמה נטענת בדפדפן, מופיע המלבן הירוק המוכר:
עם זאת, יש בקוד הזה כמה בעיות. קודם כול, אין בו ניקוי תקין של משאבים שהוקצו. שנית, באינטרנט, דפים לא נסגרים באופן אוטומטי כשאפליקציה מסיימת את הביצוע שלה, כך שהתמונה בקנבס נשמרת. עם זאת, כשאותו קוד עובר הידור מחדש באופן מקורי באמצעות
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 עם מלבן ירוק:
עם זאת, הדוגמה לא פועלת יותר באינטרנט. הדף שנוצר באמצעות Emscripten קופא מיידית במהלך הטעינה, והתמונה שעבר רינדור אף פעם לא מוצגת:
מה קרה? אצטט את התשובה מהמאמר "שימוש בממשקי 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
והאפליקציה שוב פועלת כצפוי באינטרנט:
עם זאת, ל-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:
ובאינטרנט:
למידע נוסף על רכיבים גרפיים בסיסיים, אפשר לעיין במסמכים שנוצרו באופן אוטומטי.