In Emscripten auf Leinwand zeichnen

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

Verschiedene Betriebssysteme haben unterschiedliche APIs zum Zeichnen von Grafiken. Die Unterschiede werden noch mehr verwirrend, wenn Sie plattformübergreifenden Code schreiben oder Grafiken von einem System auf ein anderes übertragen, auch wenn Sie nativen Code auf WebAssembly übertragen.

In diesem Beitrag lernen Sie verschiedene Methoden zum Zeichnen von 2D-Grafiken im Canvas-Element im Web kennen. Nutzen Sie dazu C- oder C++-Code, der mit Emscripten kompiliert wurde.

Leinwand über Embind

Wenn Sie ein neues Projekt beginnen, anstatt ein vorhandenes zu übertragen, 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.

Sehen Sie sich zunächst das folgende Beispiel von MDN an, um zu verstehen, wie Embind verwendet wird. Darin wird ein <canvas>-Element gefunden und Formen darauf gezeichnet.

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

So kann sie 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);
}

Achten Sie beim Verknüpfen dieses Codes darauf, --bind zu übergeben, um Embind zu aktivieren:

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

Anschließend kannst du 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 einem schwarzen Canvas.

Canvas-Element auswählen

Wenn Sie die von Emscripten generierte HTML-Shell mit dem vorherigen Shell-Befehl verwenden, ist der Canvas enthalten und wird für Sie eingerichtet. Es macht es einfacher, einfache Demos und Beispiele zu erstellen, aber in größeren Anwendungen können Sie den Emscripten-generierten JavaScript-Code und WebAssembly in eine HTML-Seite Ihres eigenen Designs einfügen.

Der generierte JavaScript-Code erwartet, dass das in der Eigenschaft Module.canvas gespeicherte Canvas-Element gefunden wird. Kann wie andere Moduleigenschaften während der Initialisierung festgelegt werden.

Wenn Sie den ES6-Modus verwenden (die Ausgabe auf einen Pfad mit der Erweiterung .mjs oder die Einstellung -s EXPORT_ES6 festlegen), können Sie das 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 Sie die von Emscripten generierte JavaScript-Datei laden:

<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 Computergrafiken. Wenn es in Emscripten verwendet wird, wird die unterstützte Teilmenge von OpenGL-Vorgängen in WebGL konvertiert. Wenn Ihre App auf Funktionen basiert, die in OpenGL ES 2.0 oder 3.0 unterstützt werden, aber nicht in WebGL, kann Emscripten diese ebenfalls emulieren. Sie müssen diese Funktion jedoch über die entsprechenden Einstellungen aktivieren.

Du kannst OpenGL entweder direkt oder über übergeordnete 2D- und 3D-Grafikbibliotheken verwenden. Einige davon wurden mit Emscripten ins Web übertragen. In diesem Beitrag liegt der Schwerpunkt auf 2D-Grafiken. Hierfür ist SDL2 derzeit die bevorzugte Bibliothek, da sie gut getestet wurde und das Emscripten-Back-End offiziell Upstream unterstützt.

Rechtecke 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 – z. B. zur Steuerung von Audio, Tastatur, Maus und Grafik – wurden portiert und funktionieren auch mit Emscripten im Web. So kannst du ganze Spiele, die mit SDL2 erstellt wurden, problemlos übertragen. Wenn Sie ein vorhandenes Projekt mitnehmen, lesen Sie den Abschnitt In ein Build-System einbinden in der Emscripten-Dokumentation.

Der Einfachheit halber konzentrieren wir uns in diesem Beitrag auf einen Fall mit nur einer Datei 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. 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 Canvas.

Bei diesem Code gibt es jedoch ein paar Probleme. Erstens fehlt es an einer ordnungsgemäßen Bereinigung der zugewiesenen Ressourcen. Zweitens werden Seiten im Web nicht automatisch geschlossen, wenn eine Anwendung ihre Ausführung beendet hat, sodass das Bild auf dem Canvas erhalten bleibt. Wenn jedoch derselbe Code nativ neu kompiliert wird,

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 nicht die Möglichkeit hat, das Bild zu sehen.

Ereignisschleife einbinden

Ein vollständigeres und idiomatischeres Beispiel wäre, in einer Ereignisschleife zu warten, bis der Nutzer die App beendet:

#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, in der sie Tastatur-, Maus- und andere Nutzerereignisse verarbeiten kann. Wenn der Nutzer das Fenster schließt, löst er das Ereignis SDL_QUIT 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 an:

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

Von Emscripten generierte HTML-Seite mit dem Fehlerdialogfeld &quot;Seite reagiert nicht&quot; mit der Aufforderung, entweder zu warten, bis die Seite verantwortlich wird, oder die Seite zu verlassen

Woran liegt das? Ich zitiere die Antwort aus dem Artikel „Using asynchrone Web APIs from WebAssembly“:

Die kurze Version besagt, dass der Browser alle Teile des Codes in einer Endlosschleife ausführt, indem er sie nacheinander aus der Warteschlange entnimmt. Wenn ein Ereignis ausgelöst wird, stellt der Browser den entsprechenden Handler in die Warteschlange. Bei der nächsten Schleifeniteration 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, wobei nur ein einziger Thread verwendet wird.

Bei diesem Mechanismus sollten Sie sich unbedingt daran erinnern, dass während der Ausführung Ihres benutzerdefinierten JavaScript- oder WebAssembly-Codes die Ereignisschleife blockiert wird [...]

Das vorherige Beispiel führt eine unendliche Ereignisschleife aus, während der Code selbst in einer anderen unendlichen Ereignisschleife ausgeführt wird, die implizit vom Browser bereitgestellt wird. Die innere Schleife gibt niemals die Kontrolle an die äußere Schleife aus, sodass der Browser keine externen Ereignisse verarbeiten oder Elemente auf der Seite darstellen kann.

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

Blockierung der Ereignisschleife mit Asyncify aufheben

Als Erstes können Sie Asyncify verwenden, wie in dem verknüpften Artikel beschrieben. Es handelt sich um eine Emscripten-Funktion, mit der Sie das C- oder C++-Programm "pausieren", die Steuerung der Ereignisschleife zurückgeben und das Programm reaktivieren können, wenn ein asynchroner Vorgang abgeschlossen ist.

Ein solcher asynchroner Vorgang kann sogar „Für die geringstmögliche Zeit im Ruhemodus sein“, wie in der emscripten_sleep(0) API angegeben. Durch das Einbetten in die Mitte der Schleife kann ich dafür sorgen, dass das Steuerelement bei jedem Durchlauf 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

Im Web funktioniert die Anwendung wieder wie erwartet:

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

Asyncify kann jedoch einen nicht trivialen Codegrößen-Overhead haben. Wenn sie nur für eine Ereignisschleife auf oberster Ebene in der App verwendet wird, ist die Funktion emscripten_set_main_loop möglicherweise besser geeignet.

Blockierung von Ereignisschleifen mit APIs für Hauptschleifen aufheben

emscripten_set_main_loop erfordert keine Compiler-Transformationen zum Entspannen und Zurückspulen des Aufrufstacks. Auf diese Weise wird der Aufwand bezüglich der Codegröße vermieden. Im Gegenzug sind jedoch viel mehr manuelle Änderungen am Code erforderlich.

Zuerst muss der Text der Ereignisschleife in eine separate Funktion extrahiert werden. Anschließend muss emscripten_set_main_loop mit dieser Funktion als Callback im ersten Argument, einer Framerate im zweiten Argument (0 für das native Aktualisierungsintervall) und einem booleschen Wert aufgerufen werden, der angibt, ob eine Endlosschleife (true) im dritten Argument simuliert werden soll:

emscripten_set_main_loop(callback, 0, true);

Der neu erstellte Callback hat keinen Zugriff auf die Stackvariablen in der main-Funktion. Variablen wie window und renderer müssen daher entweder in eine Heap-zugewiesene Struktur extrahiert und ihr 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 die zweite Variante entschieden). Das Ergebnis ist etwas schwieriger nachzuvollziehen, 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 des Kontrollflusses manuell sind und im Quellcode widergespiegelt werden, können sie noch einmal 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 die erste Version, bei der das Rechteck erfolgreich auf Canvas gezeichnet wurde, obwohl der Code wesentlich einfacher ist. Außerdem wird das SDL_QUIT-Ereignis – das einzige in der handle_events-Funktion verarbeitete – trotzdem im Web ignoriert.

Die richtige Integration der Ereignisschleife – entweder über Asyncify oder über emscripten_set_main_loop – zahlt sich jedoch aus, wenn Sie Animationen oder Interaktivität hinzufügen möchten.

Umgang mit Nutzerinteraktionen

Mit ein paar Änderungen am letzten Beispiel können Sie beispielsweise dafür sorgen, dass sich das Rechteck als Reaktion auf Tastaturereignisse bewegt:

#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 schließt plattformübergreifende Unterschiede und verschiedene Typen von Mediengeräten in einer einzigen API aus, ist aber immer noch eine ziemlich einfache Bibliothek. Insbesondere bei Grafiken stellt es zwar APIs zum Zeichnen von Punkten, Linien und Rechtecken bereit, aber die Implementierung komplexerer Formen und Transformationen bleibt dem Nutzer überlassen.

SDL2_gfx ist eine separate Bibliothek, die diese Lücke füllt. So kann beispielsweise ein Rechteck im Beispiel oben 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. 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, 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 Canvas.

Informationen zu weiteren Grafikprimitiven finden Sie in der Dokumentation zu automatisch generierten Elementen.