Informationen zum Rendern von 2D-Grafiken im Web aus WebAssembly mit Emscripten
Verschiedene Betriebssysteme haben unterschiedliche APIs zum Zeichnen von Grafiken. Die Unterschiede werden noch verwirrender, wenn Sie plattformübergreifenden Code schreiben oder Grafiken von einem System in ein anderes portieren, einschließlich des Portierens von nativem Code in WebAssembly.
In diesem Beitrag erfahren Sie, wie Sie mit C- oder C++-Code, der mit Emscripten kompiliert wurde, 2D-Grafiken im Canvas-Element im Web zeichnen.
Canvas über Embind
Wenn Sie ein neues Projekt starten möchten, anstatt ein vorhandenes zu portieren, ist es möglicherweise am einfachsten, die HTML Canvas API über das Bindungssystem Embind von Emscripten zu verwenden. Mit Embind können Sie direkt mit beliebigen JavaScript-Werten arbeiten.
Zum besseren Verständnis der Verwendung von Embind sehen Sie sich zuerst das folgende Beispiel von MDN an, das ein <canvas>-Element findet und Formen darauf zeichnet
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);
So kann es mit Embind in C++ transliteriert werden:
#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);
}
Achten Sie beim Verknüpfen dieses Codes darauf, --bind
zu übergeben, um Embind zu aktivieren:
emcc --bind example.cpp -o example.html
Anschließend können Sie die kompilierten Assets mit einem statischen Server bereitstellen und das Beispiel in einem Browser laden:
Canvas-Element auswählen
Wenn Sie die von Emscripten generierte HTML-Shell mit dem vorherigen Shell-Befehl verwenden, ist der Canvas bereits enthalten und für Sie eingerichtet. Das erleichtert das Erstellen einfacher Demos und Beispiele. Bei größeren Anwendungen sollten Sie das von Emscripten generierte JavaScript und WebAssembly jedoch in eine HTML-Seite Ihres eigenen Designs einfügen.
Der generierte JavaScript-Code erwartet, dass das Canvas-Element in der Eigenschaft Module.canvas
gespeichert ist. Wie andere Moduleigenschaften kann er während der Initialisierung festgelegt werden.
Wenn Sie den ES6-Modus verwenden (und die Ausgabe auf einen Pfad mit der Erweiterung .mjs
festlegen oder die Einstellung -s EXPORT_ES6
verwenden), können Sie den Canvas so übergeben:
import initModule from './emscripten-generated.mjs';
const Module = await initModule({
canvas: document.getElementById('my-canvas')
});
Wenn du eine reguläre Scriptausgabe verwendest, musst du das Module
-Objekt deklarieren, bevor du die von Emscripten generierte JavaScript-Datei lädst:
<script>
var Module = {
canvas: document.getElementById('my-canvas')
};
</script>
<script src="emscripten-generated.js"></script>
OpenGL und SDL2
OpenGL ist eine beliebte plattformübergreifende API für Computergrafik. Wenn es in Emscripten verwendet wird, kümmert es sich um die Umwandlung der unterstützten OpenGL-Vorgänge in WebGL. Wenn Ihre App Funktionen nutzt, die in OpenGL ES 2.0 oder 3.0, aber nicht in WebGL unterstützt werden, kann Emscripten auch diese emulieren. Dazu müssen Sie sie jedoch über die entsprechenden Einstellungen aktivieren.
Sie können OpenGL entweder direkt oder über höhere 2D- und 3D-Grafikbibliotheken verwenden. Einige davon wurden mit Emscripten ins Web portiert. In diesem Beitrag konzentriere ich mich auf 2D-Grafiken. SDL2 ist dafür derzeit die bevorzugte Bibliothek, weil sie sich umfassend getestet wurde und das offiziell vorgeschaltete Emscripten-Back-End unterstützt.
Rechteck zeichnen
Auf der offiziellen Website steht im Abschnitt „Informationen zu SDL“ Folgendes:
Simple DirectMedia Layer ist eine plattformübergreifende Entwicklungsbibliothek, die Low-Level-Zugriff auf Audio, Tastatur, Maus, Joystick und Grafikhardware über OpenGL und Direct3D bietet.
All diese Funktionen – die Steuerung von Audio, Tastatur, Maus und Grafik – wurden portiert und funktionieren auch mit Emscripten im Web, sodass Sie ganze Spiele, die mit SDL2 erstellt wurden, problemlos portieren können. Wenn Sie ein vorhandenes Projekt übertragen, lesen Sie den Abschnitt „Integration mit einem Build-System“ in der Emscripten-Dokumentation.
Der Einfachheit halber konzentriere ich mich in diesem Beitrag auf eine Einzeldatei und übersetze das vorherige Rechteckbeispiel in 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
}
Wenn du eine Verknüpfung mit Emscripten herstellen möchtest, musst du -s USE_SDL=2
verwenden. Damit wird Emscripten angewiesen, die bereits in WebAssembly vorkompilierte SDL2-Bibliothek abzurufen und mit Ihrer Hauptanwendung zu verknüpfen.
emcc example.cpp -o example.html -s USE_SDL=2
Wenn das Beispiel im Browser geladen wird, sehen Sie das bekannte grüne Rechteck:
Bei diesem Code gibt es jedoch einige Probleme. Erstens fehlt es an einer ordnungsgemäßen Bereinigung der zugewiesenen Ressourcen. Zweitens: Im Web werden Seiten nicht automatisch geschlossen, wenn eine Anwendung beendet wurde. Das Bild auf dem Canvas bleibt also erhalten. Wird derselbe Code jedoch nativ neu kompiliert,
clang example.cpp -o example -lSDL2
ausgeführt wird, blinkt das erstellte Fenster nur kurz und schließt sich beim Beenden sofort, sodass der Nutzer das Bild nicht sehen kann.
Ereignisschleife einbinden
Ein vollständigeres und idiomatischeres Beispiel würde so aussehen: In einer Ereignisschleife wird gewartet, bis der Nutzer die Anwendung schließt:
#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();
}
Nachdem das Bild in einem Fenster gezeichnet wurde, wartet die Anwendung in einer Schleife, in der sie Tastatur-, Maus- und andere Nutzerereignisse verarbeiten kann. Wenn der Nutzer das Fenster schließt, löst er ein SDL_QUIT
-Ereignis aus, das abgefangen wird, um die Schleife zu beenden. Nachdem die Schleife beendet wurde, führt die Anwendung die Bereinigung durch und beendet sich dann selbst.
Das Kompilieren dieses Beispiels unter Linux funktioniert jetzt wie erwartet und zeigt ein 300 x 300-Fenster mit einem grünen Rechteck:
Im Web funktioniert das Beispiel jedoch nicht mehr. Die von Emscripten generierte Seite friert beim Laden sofort ein und das gerenderte Bild wird nie angezeigt:
Was ist passiert? Ich zitiere die Antwort aus dem Artikel „Asynchrone Web-APIs von WebAssembly verwenden“:
Kurz gesagt: Der Browser führt alle Codeteile in einer Art Endlosschleife aus, indem er sie nacheinander aus der Warteschlange nimmt. Wenn ein Ereignis ausgelöst wird, stellt der Browser den entsprechenden Handler in die Warteschlange. Bei der nächsten Iteration der Schleife wird er aus der Warteschlange genommen und ausgeführt. Dieser Mechanismus ermöglicht die Simulation von Nebenläufigkeit und die Ausführung vieler paralleler Vorgänge bei nur einem Thread.
Wichtig ist, dass der Ereignis-Loop blockiert ist, während Ihr benutzerdefinierter JavaScript- oder WebAssembly-Code ausgeführt wird. […]
Im vorherigen Beispiel wird eine endlose Ereignisschleife ausgeführt, während der Code selbst in einer anderen endlosen Ereignisschleife ausgeführt wird, die implizit vom Browser bereitgestellt wird. Der innere Loop übergibt die Kontrolle nie an den äußeren, sodass der Browser keine Möglichkeit hat, externe Ereignisse zu verarbeiten oder Elemente auf die Seite zu zeichnen.
Es gibt zwei Möglichkeiten, dieses Problem zu beheben.
Ereignisschleife mit Asyncify entsperren
Wie im verknüpften Artikel beschrieben, kannst du zuerst Asyncify verwenden. Es handelt sich um eine Emscripten-Funktion, mit der das C- oder C++-Programm "angehalten", die Steuerung der Ereignisschleife zurückgegeben und das Programm aktiviert werden kann, sobald ein asynchroner Vorgang abgeschlossen ist.
Ein derartiger asynchroner Vorgang kann über die emscripten_sleep(0)
API sogar im Ruhemodus für die geringstmögliche Zeit ausgeführt werden. Durch das Einbetten in die Mitte der Schleife kann ich dafür sorgen, dass das Steuerelement bei jeder Iteration an die Ereignisschleife des Browsers zurückgegeben wird. Die Seite bleibt reaktionsschnell und kann alle Ereignisse verarbeiten:
#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();
}
Dieser Code muss jetzt mit aktiviertem Asyncify kompiliert werden:
emcc example.cpp -o example.html -s USE_SDL=2 -s ASYNCIFY
Und die Anwendung funktioniert wieder wie erwartet im Web:
Asyncify kann jedoch einen nicht unerheblichen Overhead bei der Codegröße verursachen. Wenn es nur für einen Ereignis-Loop der obersten Ebene in der Anwendung verwendet wird, ist die Funktion emscripten_set_main_loop
möglicherweise die bessere Option.
Blockierung des Ereignis-Loops mit „main loop“-APIs aufheben
emscripten_set_main_loop
erfordert keine Compilertransformationen zum Auf- und Zurückspulen des Aufrufstacks und vermeidet so den Overhead bei der Codegröße. Als Gegenleistung sind jedoch viel mehr manuelle Änderungen am Code erforderlich.
Zuerst muss der Text der Ereignisschleife in eine separate Funktion extrahiert werden. Dann muss emscripten_set_main_loop
mit dieser Funktion als Callback im ersten Argument, mit einem FPS im zweiten Argument (0
für das native Aktualisierungsintervall) und einem booleschen Wert aufgerufen werden, der angibt, ob im dritten Argument eine Endlosschleife (true
) simuliert werden soll:
emscripten_set_main_loop(callback, 0, true);
Der neu erstellte Rückruf hat keinen Zugriff auf die Stackvariablen in der main
-Funktion. Variablen wie window
und renderer
müssen daher entweder in einen Heap-allozierten Datenstruktur extrahiert und der Pointer über die emscripten_set_main_loop_arg
-Variante der API übergeben oder in globale static
-Variablen extrahiert werden. Ich habe mich aus Gründen der Einfachheit für die letztere Option entschieden. Das Ergebnis ist etwas schwerer zu verstehen, aber es wird dasselbe Rechteck wie im letzten Beispiel gezeichnet:
#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();
}
Da alle Änderungen an der Ablaufsteuerung manuell sind und im Quellcode berücksichtigt werden, kann er wieder ohne die Asyncify-Funktion kompiliert werden:
emcc example.cpp -o example.html -s USE_SDL=2
Dieses Beispiel mag nutzlos erscheinen, da es genauso funktioniert wie in der ersten Version, bei der das Rechteck erfolgreich auf dem Canvas gezeichnet wurde, obwohl der Code viel einfacher ist. Das SDL_QUIT
-Ereignis – das einzige, das in der handle_events
-Funktion verarbeitet wird – wird im Web trotzdem ignoriert.
Die ordnungsgemäße Einbindung der Ereignisschleife – entweder über Asyncify oder über emscripten_set_main_loop
– zahlt sich jedoch aus, wenn Sie irgendeine Art von Animation oder Interaktivität hinzufügen.
Umgang mit Nutzerinteraktionen
Mit einigen Änderungen am letzten Beispiel können Sie das Rechteck beispielsweise als Reaktion auf Tastaturereignisse bewegen:
#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();
}
Andere Formen mit SDL2_gfx zeichnen
SDL2 abstrahiert plattformübergreifende Unterschiede und verschiedene Arten von Mediengeräten in einer einzigen API, ist aber immer noch eine ziemlich Low-Level-Bibliothek. Insbesondere für Grafiken bietet sie APIs zum Zeichnen von Punkten, Linien und Rechtecken, die Implementierung komplexerer Formen und Transformationen bleibt jedoch dem Nutzer überlassen.
SDL2_gfx ist eine separate Bibliothek, die diese Lücke schließt. So können Sie beispielsweise ein Rechteck im obigen Beispiel durch einen Kreis ersetzen:
#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();
}
Jetzt muss auch die SDL2_gfx-Bibliothek mit der Anwendung verknüpft werden. Die Vorgehensweise ist ähnlich wie bei 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
Hier sind die Ergebnisse, die unter Linux ausgeführt werden:
Und im Web:
Informationen zu weiteren grafischen Primitiven finden Sie in den automatisch generierten Dokumenten.