In Emscripten auf Leinwand zeichnen

Informationen zum Rendern von 2D-Grafiken im Web aus WebAssembly mit Emscripten.

Unterschiedliche 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, anstatt ein vorhandenes zu portieren, ist es am einfachsten, die Canvas API über das Bindungssystem Embind von Emscripten zu verwenden. Mit Embind können Sie direkt mit beliebigen JavaScript-Werten arbeiten.

Um die Verwendung von Embind zu verstehen, sehen Sie sich zuerst das folgende Beispiel von MDN an, in dem ein <canvas>-Element gefunden und einige Formen darauf gezeichnet werden.

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++ transkribiert 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);
}

Wenn du diesen Code verknüpfst, musst du --bind übergeben, um Embind zu aktivieren:

emcc --bind example.cpp -o example.html

Anschließend können Sie die kompilierten Assets über einen statischen Server bereitstellen und das Beispiel in einem Browser laden:

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

Canvas-Element auswählen

Wenn Sie die von Emscripten generierte HTML-Shell mit dem vorstehenden 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 Ihrer eigenen Designvorlage einfügen.

Der generierte JavaScript-Code erwartet, dass das Canvas-Element in der Property Module.canvas gespeichert ist. Wie andere Moduleigenschaften kann sie während der Initialisierung festgelegt werden.

Wenn Sie den ES6-Modus verwenden (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 Anwendung Funktionen verwendet, die in OpenGL ES 2.0 oder 3.0, aber nicht in WebGL unterstützt werden, kann Emscripten auch diese emulieren. Sie müssen sie jedoch über die entsprechenden Einstellungen aktivieren.

Sie können OpenGL entweder direkt oder über 2D- und 3D-Grafikbibliotheken höherer Ebene verwenden. Einige davon wurden mit Emscripten ins Web portiert. In diesem Beitrag konzentriere ich mich auf 2D-Grafiken. Dafür ist derzeit SDL2 die bevorzugte Bibliothek, da sie gut getestet wurde und das Emscripten-Backend offiziell im Upstream unterstützt.

Rechteck zeichnen

Im Abschnitt „Über SDL“ auf der offiziellen Website steht:

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

Alle diese Funktionen – Steuerung von Audio, Tastatur, Maus und Grafik – wurden portiert und funktionieren auch mit Emscripten im Web. So können Sie ganze mit SDL2 erstellte Spiele ohne großen Aufwand portieren. Wenn Sie ein vorhandenes Projekt portieren, lesen Sie den Abschnitt Integrating with a build system (In ein Build-System einbinden) in der Emscripten-Dokumentation.

Der Einfachheit halber konzentriere ich mich in diesem Beitrag auf den Fall mit einer einzelnen Datei und übertrage 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 Sie eine Verknüpfung mit Emscripten herstellen, müssen Sie -s USE_SDL=2 verwenden. Dadurch 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.

Dieser Code weist jedoch einige Probleme auf. Erstens werden nicht alle zugewiesenen Ressourcen ordnungsgemäß bereinigt. Zweitens: Im Web werden Seiten nicht automatisch geschlossen, wenn eine Anwendung beendet wurde. Das Bild auf dem Canvas bleibt also erhalten. Wenn derselbe Code jedoch nativ mit

clang example.cpp -o example -lSDL2

und ausgeführt wird, blinkt das erstellte Fenster nur kurz und wird beim Verlassen sofort geschlossen, sodass der Nutzer keine Möglichkeit hat, das Bild zu sehen.

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. Nach dem Beenden der Schleife führt die Anwendung die Bereinigung durch und beendet sich dann selbst.

Jetzt funktioniert die Kompilierung dieses Beispiels unter Linux wie erwartet und es wird ein Fenster mit 300 × 300 Pixeln mit einem grünen Rechteck angezeigt:

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

Das Beispiel funktioniert jedoch nicht mehr im Web. Die von Emscripten generierte Seite friert beim Laden sofort ein und das gerenderte Bild wird nie angezeigt:

Eine von Emscripten generierte HTML-Seite mit dem Fehlerdialogfeld „Page Unresponsive“ (Seite reagiert nicht), in dem empfohlen wird, entweder zu warten, bis die Seite reagiert, oder die Seite zu schließen

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. Mit diesem Mechanismus können Sie die Parallelität simulieren und viele parallele Vorgänge ausführen, während nur ein einzelner Thread verwendet wird.

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 ist eine Emscripten-Funktion, mit der das C- oder C++-Programm „pausiert“ werden kann, die Kontrolle an den Ereignis-Loop zurückgegeben und das Programm wieder gestartet werden kann, wenn ein asynchroner Vorgang abgeschlossen ist.

Ein solcher asynchroner Vorgang kann sogar „für die kürzest mögliche Zeit inaktiv sein“, was über die emscripten_sleep(0) API ausgedrückt wird. 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

Die Anwendung funktioniert jetzt wieder wie erwartet im Web:

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

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 Aufheben und Zurückspulen des Aufrufstacks und vermeidet so den Overhead bei der Codegröße. Dafür sind jedoch viel mehr manuelle Änderungen am Code erforderlich.

Zuerst muss der Body des Ereignis-Loops in eine separate Funktion extrahiert werden. Anschließend muss emscripten_set_main_loop mit dieser Funktion als Callback im ersten Argument, einem FPS im zweiten Argument (0 für das native Aktualisierungsintervall) und einem booleschen Wert, der angibt, ob ein Endlos-Loop (true) simuliert werden soll, im dritten Argument aufgerufen werden:

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 nicht anders funktioniert als die erste Version, in der das Rechteck erfolgreich auf dem Canvas gezeichnet wurde, obwohl der Code viel einfacher war. Außerdem wird das SDL_QUIT-Ereignis, das einzige, das in der handle_events-Funktion verarbeitet wird, im Web ohnehin ignoriert.

Eine korrekte Ereignisschleifenintegration – entweder über Asyncify oder über emscripten_set_main_loop – lohnt sich jedoch, wenn Sie Animationen oder Interaktivität hinzufügen möchten.

Nutzerinteraktionen verarbeiten

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. Das funktioniert ä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

Und hier sind die Ergebnisse unter Linux:

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

Im Web:

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

Weitere Grafikprimitive finden Sie in den automatisch generierten Dokumenten.