In Emscripten auf Leinwand zeichnen

Hier erfahren Sie, wie Sie mit Emscripten 2D-Grafiken mit WebAssembly im Web rendern können.

Verschiedene Betriebssysteme haben unterschiedliche APIs zum Zeichnen von Grafiken. Die Unterschiede werden noch verwirrender, wenn Sie einen plattformübergreifenden Code schreiben oder Grafiken von einem System auf ein anderes übertragen, einschließlich beim Portieren von nativem Code zu WebAssembly.

In diesem Beitrag lernen Sie einige Methoden kennen, mit denen Sie 2D-Grafiken in das Canvas-Element im Web aus dem mit Emscripten kompilierten C- oder C++-Code zeichnen können.

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.

Um zu verstehen, wie Embind verwendet wird, sehen Sie sich zuerst das folgende Beispiel von MDN an, in dem ein <canvas> gefunden wird. und zeichnet einige Formen darauf.

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:

Von Emscripten generierte HTML-Seite mit einem grünen Rechteck auf einer schwarzen Leinwand

Canvas-Element auswählen

Wenn Sie die von Emscripten generierte HTML-Shell mit dem vorherigen Shell-Befehl verwenden, ist das Canvas-Element enthalten und wird für Sie eingerichtet. Es erleichtert das Erstellen einfacher Demos und Beispiele. Bei größeren Anwendungen sollten Sie jedoch den von Emscripten generierten JavaScript- und WebAssembly-Code in eine HTML-Seite Ihres eigenen Designs einbinden.

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 Sie eine normale Skriptausgabe verwenden, müssen Sie das Module-Objekt deklarieren, bevor die von Emscripten generierte JavaScript-Datei geladen wird:

<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. Bei der Verwendung in Emscripten wird die unterstützte Teilmenge von OpenGL-Vorgängen in WebGL konvertiert. Wenn Ihre App Funktionen benötigt, 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 aber über die entsprechenden Einstellungen aktivieren.

Sie können OpenGL entweder direkt oder über übergeordnete 2D- und 3D-Grafikbibliotheken verwenden. Einige davon wurden mit Emscripten ins Web übertragen. In diesem Beitrag konzentriere ich mich auf 2D-Grafiken. SDL2 ist dafür derzeit die bevorzugte Bibliothek, da sie sich gut getestet wurde und offiziell das Emscripten-Back-End unterstützt.

Rechteck zeichnen

„Informationen zu SDL“ auf der offiziellen Website steht:

Simple DirectMedia Layer ist eine plattformübergreifende Entwicklungsbibliothek für den Low-Level-Zugriff auf Audio, Tastatur, Maus, Joystick und Grafikhardware über OpenGL und Direct3D.

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
}

Für die Verknüpfung mit Emscripten müssen Sie -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:

Von Emscripten generierte HTML-Seite mit einem grünen Rechteck auf einem schwarzen quadratischen Canvas

Bei diesem Code gibt es jedoch einige Probleme. Erstens fehlt es an einer ordnungsgemäßen Bereinigung der zugewiesenen Ressourcen. Zweitens werden Seiten im Web nicht automatisch geschlossen, wenn eine Anwendung die Ausführung beendet hat, sodass das Bild auf dem Canvas erhalten bleibt. 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 aussehen, als würde in einer Ereignisschleife warten, bis der Benutzer 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 nun in einer Schleife, wo 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 verlassen. 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:

Ein quadratisches Linux-Fenster mit schwarzem Hintergrund und einem grünen Rechteck.

Im Web funktioniert das Beispiel jedoch nicht mehr. Die von Emscripten generierte Seite friert sofort während des Ladevorgangs ein und zeigt nie das gerenderte Bild:

Von Emscripten generierte HTML-Seite, überlagert „Seite reagiert nicht“ Fehlermeldung, in der vorgeschlagen wird, zu warten, bis die Seite für die Verantwortung verantwortlich ist, oder die Seite zu verlassen

Was ist passiert? Ich zitiere die Antwort aus dem Artikel Using asynchrone Web APIs from WebAssembly (Asynchrone Web-APIs von WebAssembly verwenden):

Die kurze Version besagt, dass der Browser alle Teile des Codes in einer Art Endlosschleife ausführt, indem er sie nacheinander aus der Warteschlange holt. Wenn ein Ereignis ausgelöst wird, stellt der Browser den entsprechenden Handler in die Warteschlange und wird bei der nächsten Wiederholungsschleife 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.

Das Wichtigste bei diesem Mechanismus ist, dass die Ereignisschleife während der Ausführung Ihres benutzerdefinierten JavaScript- (oder WebAssembly-)Codes blockiert wird [...]

Im vorherigen Beispiel wird eine Endlosschleife ausgeführt, während der Code selbst in einer anderen Endlosereignisschleife ausgeführt wird, die implizit vom Browser bereitgestellt wird. Die innere Schleife verzichtet niemals auf die äußere, sodass der Browser keine Möglichkeit hat, externe Ereignisse zu verarbeiten oder Elemente auf die Seite zu ziehen.

Es gibt zwei Möglichkeiten, dieses Problem zu beheben.

Blockierung der Ereignisschleife mit Asyncify wird aufgehoben

Wie im verknüpften Artikel beschrieben, können Sie zuerst Asyncify verwenden. Dabei handelt es sich um eine Emscripten-Funktion, mit der sich „pausieren“ und dem C- oder C++-Programm, geben Sie die Steuerung der Ereignisschleife zurück und aktivieren Sie das Programm, 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 sicherstellen, dass das Steuerelement bei jeder Iteration an die Ereignisschleife des Browsers zurückgegeben wird und die Seite responsiv bleibt und alle Ereignisse verarbeiten kann:

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

Von Emscripten generierte HTML-Seite mit einem grünen Rechteck auf einem schwarzen quadratischen Canvas

Allerdings kann Asyncify einen nicht unerheblichen Code-Overhead haben. Wenn sie nur für eine Ereignisschleife der obersten Ebene in der Anwendung verwendet wird, kann es besser sein, die Funktion emscripten_set_main_loop zu verwenden.

Blockierung der Ereignisschleife mit „Hauptschleife“ wird aufgehoben APIs

emscripten_set_main_loop erfordert keine Compiler-Transformationen zum Entwinden und Zurückspulen des Aufrufstacks. Auf diese Weise wird der Aufwand für die Codegröße vermieden. Dafür 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 Callback hat keinen Zugriff auf die Stackvariablen in der main-Funktion. Daher müssen Variablen wie window und renderer entweder in eine Heap-Zuweisungsstruktur extrahiert und deren Zeiger über die emscripten_set_main_loop_arg-Variante der API übergeben oder in globale static-Variablen extrahiert werden (der Einfachheit halber habe ich mich für Letztere entschieden). Das Ergebnis ist etwas schwieriger nachzuvollziehen, aber es zeichnet dasselbe Rechteck wie im letzten Beispiel:

#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 der Ablaufsteuerung manuell erfolgen und im Quellcode widergespiegelt werden, kann er ohne die Asyncify-Funktion erneut 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 nur wenigen Änderungen im Vergleich zum letzten Beispiel können Sie das Rechteck als Reaktion auf Tastaturereignisse verschieben lassen:

#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 einem einzigen API, es handelt sich aber trotzdem um eine relativ einfache Bibliothek. Insbesondere für Grafiken bietet diese API 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. Beispielsweise kann damit ein Rechteck im obigen Beispiel durch einen Kreis ersetzt werden:

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

Ein quadratisches Linux-Fenster mit schwarzem Hintergrund und einem grünen Kreis.

Und im Web:

Von Emscripten generierte HTML-Seite mit einem grünen Kreis auf einem schwarzen Quadrat

Informationen zu weiteren grafischen Primitiven finden Sie in den automatisch generierten Dokumenten.