Rysowanie na płótnie w Emscripten

Dowiedz się, jak renderować grafikę 2D w internecie z użyciem WebAssembly za pomocą Emscripten.

Różne systemy operacyjne mają różne interfejsy API do rysowania grafiki. Różnice stają się jeszcze bardziej mylące, gdy piszesz kod na wiele platform lub przenosisz grafikę z jednego systemu na inny, w tym podczas przenoszenia kodu natywnych na WebAssembly.

Z tego artykułu dowiesz się, jak za pomocą kodu C lub C++ skompilowanego za pomocą Emscripten rysować grafikę 2D na elemencie canvas w internecie.

Jeśli zaczynasz nowy projekt, a nie próbujesz przenieść istniejącego, najłatwiej będzie użyć interfejsu Canvas API w ramach systemu wiązania Emscripten Embind. Embind umożliwia bezpośrednie operowanie dowolnymi wartościami JavaScript.

Aby dowiedzieć się, jak korzystać z Embind, najpierw zapoznaj się z tym przykładem z MDN, który znajduje element <canvas> i rysuje na nim różne kształty.

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

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

Oto jak można przekształcić ten kod na C++ za pomocą 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);
}

Podczas łączenia tego kodu pamiętaj, aby przekazać parametr --bind, aby włączyć Embind:

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

Następnie możesz udostępniać skompilowane zasoby za pomocą serwera statycznego i wczytywać przykład w przeglądarce:

Wygenerowana przez Emscripten strona HTML z zielonym prostokątem na czarnym tle.

Wybieranie elementu na płótnie

Jeśli używasz powłoki HTML wygenerowanej przez Emscripten za pomocą poprzedzającego polecenia powłoki, otrzymasz już skonfigurowane płótno. Ułatwia to tworzenie prostych demonstracji i przykładów, ale w większych aplikacjach warto umieścić wygenerowany przez Emscripten kod JavaScript i WebAssembly na stronie HTML zaprojektowanej przez siebie.

Wygenerowany kod JavaScript ma znajdować element canvas przechowywany w usłudze Module.canvas. Podobnie jak inne właściwości modułu, można je ustawić podczas inicjalizacji.

Jeśli używasz trybu ES6 (ustawiasz wyjście na ścieżkę z rozszerzeniem .mjs lub używasz ustawienia -s EXPORT_ES6), możesz przekazać kanwę w ten sposób:

import initModule from './emscripten-generated.mjs';

const Module = await initModule({
  canvas: document.getElementById('my-canvas')
});

Jeśli używasz zwykłego wyjścia skryptu, przed załadowaniem pliku JavaScript wygenerowanego przez Emscripten musisz zadeklarować obiekt Module:

<script>
var Module = {
  canvas: document.getElementById('my-canvas')
};
</script>
<script src="emscripten-generated.js"></script>

OpenGL i SDL2

OpenGL to popularny wieloplatformowy interfejs API do obsługi grafiki komputerowej. W Emscripten konwertuje obsługiwany podzbiór operacji OpenGL na WebGL. Jeśli Twoja aplikacja korzysta z funkcji obsługiwanych przez OpenGL ES 2.0 lub 3.0, ale nie przez WebGL, Emscripten może też się tym zająć, ale musisz wyrazić zgodę w odpowiednich ustawieniach.

Interfejs OpenGL możesz używać bezpośrednio lub za pomocą bibliotek graficznych 2D i 3D wyższego poziomu. Kilka z nich zostało przeportowanych do przeglądarki za pomocą Emscripten. W tym poście skupiam się na grafice 2D, dlatego preferowaną biblioteką jest obecnie SDL2, ponieważ została dobrze przetestowana i obsługuje oficjalnie upstreamowy backend Emscripten.

Rysowanie prostokąta

W sekcji „O SDL” na oficjalnej stronie internetowej czytamy:

Simple DirectMedia Layer to platforma programistyczna na potrzeby tworzenia aplikacji na różne platformy, która zapewnia dostęp do niskopoziomowych funkcji dźwięku, klawiatury, myszy, joysticka i sprzętu graficznego za pomocą OpenGL i Direct3D.

Wszystkie te funkcje (sterowanie dźwiękiem, klawiaturą, myszą i grafiką) zostały przeniesione i działają w Emscripten w internecie, dzięki czemu możesz bez problemu przenosić całe gry stworzone za pomocą SDL2. Jeśli przenosisz istniejący projekt, zapoznaj się z sekcją „Integracja z systemem kompilacji” w dokumentacji Emscripten.

W tym poście skupię się na tłumaczeniu pojedynczego pliku i przełożę wcześniejszy przykład prostokąta na 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
}

Aby połączyć się z Emscripten, musisz użyć -s USE_SDL=2. Spowoduje to pobranie przez Emscripten biblioteki SDL2, która została już skompilowana do WebAssembly, i połączenie jej z główną aplikacją.

emcc example.cpp -o example.html -s USE_SDL=2

Gdy przykład zostanie załadowany w przeglądarce, zobaczysz znajomy zielony prostokąt:

Wygenerowana przez Emscripten strona HTML z zielonym prostokątem na czarnym kwadratowym polu.

Ten kod zawiera jednak kilka problemów. Po pierwsze, brakuje w nim odpowiedniego czyszczenia przydzielonych zasobów. Po drugie, w internecie strony nie są zamykane automatycznie po zakończeniu działania aplikacji, więc obraz na kanwie jest zachowany. Jednak gdy ten sam kod zostanie ponownie skompilowany natywnie za pomocą

clang example.cpp -o example -lSDL2

i wykonane, utworzone okno będzie tylko krótko migać, a po zamknięciu natychmiast się zamknie, więc użytkownik nie będzie miał możliwości zobaczenia obrazu.

Integracja pętli zdarzeń

Bardziej kompletny i idiomatyczny przykład wymaga oczekiwania w pętli zdarzeń, aż użytkownik zdecyduje się zamknąć aplikację:

#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();
}

Po narysowaniu obrazu w oknie aplikacja czeka w pętli, w której może przetwarzać zdarzenia związane z klawiaturą, myszką i innymi działaniami użytkownika. Gdy użytkownik zamknie okno, wywoła zdarzenie SDL_QUIT, które zostanie przechwycone, aby zakończyć pętlę. Po zakończeniu pętli aplikacja usunie dane i się zamknie.

Kompilacja tego przykładu w systemie Linux działa zgodnie z oczekiwaniami i wyświetla okno o wymiarach 300 x 300 z zielonym prostokątem:

Kwadratowe okno systemu Linux z czarnym tłem i zielonym prostokątem.

Przykład nie działa już w internecie. Strona wygenerowana przez Emscripten zawiesza się natychmiast podczas wczytywania i nigdy nie wyświetla wyrenderowanego obrazu:

Wygenerowana przez Emscripten strona HTML z wyświetlonym oknem z komunikatem o błędzie „Strona nie odpowiada” sugerującym, aby poczekać, aż strona zacznie działać, lub ją zamknąć

Co się stało? Oto odpowiedź z artykułu „Korzystanie z asynchronicznych interfejsów API dla stron internetowych w ramach WebAssembly”:

W skrócie: przeglądarka uruchamia wszystkie elementy kodu w nieskończonej pętli, pobierając je z kolejki pojedynczo. Gdy zostanie wywołane jakieś zdarzenie, przeglądarka umieszcza odpowiedni moduł obsługi w kole, a w następnej iteracji pętli pobiera go z niej i wykonuje. Ten mechanizm umożliwia symulowanie współbieżności i wykonywanie wielu operacji równoległych przy użyciu tylko jednego wątku.

Ważne jest, aby pamiętać, że podczas wykonywania niestandardowego kodu JavaScript (lub WebAssembly) pętla zdarzeń jest zablokowana […]

W powyższym przykładzie wykonywana jest nieskończona pętla zdarzeń, a sam kod działa w ramach innej nieskończonej pętli zdarzeń, która jest domyślnie udostępniana przez przeglądarkę. Wewnętrzny obwód nigdy nie przekazuje kontroli nad sobą zewnętrznemu, więc przeglądarka nie ma możliwości przetworzenia zewnętrznych zdarzeń ani narysowania elementów na stronie.

Ten problem można rozwiązać na 2 sposoby.

Odblokowywanie pętli zdarzeń za pomocą Asyncify

Najpierw, zgodnie z instrukcjami w powiązanym artykule, możesz użyć funkcji Asyncify. Jest to funkcja Emscripten, która umożliwia „wstrzymanie” programu C lub C++, przekazanie kontroli z powrotem pętli zdarzeń i wybudzenie programu po zakończeniu niektórych operacji asynchronicznych.

Taka operacja asynchroniczna może nawet polegać na „uśpieniu na minimalny możliwy czas”, co jest wyrażane za pomocą interfejsu API emscripten_sleep(0). Umieszczenie go w środku pętli zapewnia, że w każdej iteracji kontrola wraca do pętli zdarzeń przeglądarki, a strona pozostaje responsywna i może obsługiwać wszystkie zdarzenia:

#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();
}

Kod należy teraz skompilować z włączoną opcją Asyncify:

emcc example.cpp -o example.html -s USE_SDL=2 -s ASYNCIFY

Aplikacja działa teraz prawidłowo w przeglądarce:

Wygenerowana przez Emscripten strona HTML z zielonym prostokątem na czarnym kwadratowym polu.

Jednak Asyncify może mieć niebagatelny narzut na rozmiar kodu. Jeśli służy ona tylko do pętli zdarzeń najwyższego poziomu w aplikacji, lepszym rozwiązaniem może być użycie funkcji emscripten_set_main_loop.

Odblokowywanie pętli zdarzeń za pomocą interfejsów API „main loop”

emscripten_set_main_loop nie wymaga żadnych przekształceń kompilatora na potrzeby rozwijania i zwijania stosu wywołań, dzięki czemu unikamy nadmiaru kodu. W zamian wymaga jednak znacznie więcej ręcznych modyfikacji kodu.

Najpierw należy wyodrębnić ciało pętli zdarzeń w osobnej funkcji. Następnie należy wywołać funkcję emscripten_set_main_loop, podając ją jako funkcję wywołania zwrotnego w pierwszym argumencie, podając w drugim argumencie liczbę klatek na sekundę (0 dla natywnego interwału odświeżania) oraz podając w trzecim argumencie wartość logiczną wskazującą, czy należy symulować nieskończoną pętlę (true):

emscripten_set_main_loop(callback, 0, true);

Nowo utworzona funkcja wywołania zwrotnego nie będzie miała dostępu do zmiennych stosu w funkcji main, więc zmienne takie jak windowrenderer należy wyodrębnić w strukturze alokowanej na stosie i przekazać jej wskaźnik za pomocą wariantu emscripten_set_main_loop_arg interfejsu API lub wyodrębnić w globalnych zmiennych static (w tym przypadku zastosowano tę drugą opcję ze względu na prostotę). Wynik jest nieco trudniejszy do odczytania, ale rysuje ten sam prostokąt co w ostatnim przykładzie:

#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();
}

Wszystkie zmiany przepływu sterowania są wprowadzane ręcznie i odzwierciedlone w źródłowym kodzie, dlatego można go skompilować bez funkcji Asyncify:

emcc example.cpp -o example.html -s USE_SDL=2

Ten przykład może wydawać się bezużyteczny, ponieważ działa tak samo jak pierwsza wersja, w której prostokąt został narysowany na płótnie, mimo że kod jest znacznie prostszy, a zdarzenie SDL_QUIT – jedyne obsługiwane w funkcji handle_events – jest w ogóle ignorowane w internecie.

Jednak prawidłowa integracja pętli zdarzeń (za pomocą Asyncify lub emscripten_set_main_loop) opłaca się, jeśli zdecydujesz się dodać animację lub interaktywność.

Obsługa interakcji z użytkownikiem

Na przykład po wprowadzeniu kilku zmian w ostatnim przykładzie możesz sprawić, aby prostokąt poruszał się w odpowiedzi na zdarzenia związane z klawiaturą:

#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();
}

Rysowanie innych kształtów za pomocą SDL2_gfx

SDL2 abstrahuje różnice między platformami i różne typy urządzeń multimedialnych w jednym interfejsie API, ale nadal jest to biblioteka na dość niskim poziomie. W przypadku grafiki interfejs API udostępnia funkcje do rysowania punktów, linii i prostokątów, ale implementacja bardziej złożonych kształtów i transformacji należy do użytkownika.

SDL2_gfx to osobna biblioteka, która wypełnia tę lukę. Możesz na przykład zastąpić prostokąt w przykładzie powyżej okręgiem:

#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();
}

Teraz bibliotekę SDL2_gfx trzeba też połączyć z aplikacją. Jest to podobne do 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

A tak wyglądają wyniki w systemie Linux:

Kwadratowe okno Linuxa z czarnym tłem i zielonym okręgiem.

W internecie:

Wygenerowana przez Emscripten strona HTML z zielonym okręgiem na czarnym kwadratowym polu.

Więcej informacji o prostych elementach graficznych znajdziesz w dokumentach generowanych automatycznie.