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 posta dowiesz się, jak rysować grafikę 2D na elemencie canvas w internecie, korzystając z kodu C lub C++ skompilowanego z użyciem Emscripten.

Płótno za pomocą Embind

Jeśli zamiast modyfikować istniejący projekt, tworzysz nowy, najłatwiejszym może być użycie interfejsu HTML Canvas API za pomocą systemu powiązań Emscripten Embind. Embind umożliwia bezpośrednie wykonywanie działań na dowolnych wartościach 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);

Transliterację na język C++ za pomocą Embind znajdziesz w następujący sposób:

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

Łącząc ten kod, przekaż --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 canvas

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 dane wyjściowe jako ścieżkę z rozszerzeniem .mjs lub używasz ustawienia -s EXPORT_ES6), możesz przekazać obszar roboczy w ten sposób:

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

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

Jeśli używasz zwykłych danych wyjściowych skryptu, przed wczytaniem wygenerowanego przez Emscripten pliku JavaScript 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 grafiki komputerowej. W Emscripten zajmuje się konwersją obsługiwanego podzbioru 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 skupię się na grafice 2D, a SDL2 jest obecnie preferowaną biblioteką, ponieważ została dobrze przetestowana i oficjalnie obsługuje backend Emscripten.

Rysowanie prostokąta

W sekcji „Oprogramowanie 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 niskopoziomowego sprzętu audio, klawiatury, myszy, joysticka i karty graficznej za pomocą OpenGL i Direct3D.

Wszystkie te funkcje – sterowanie dźwiękiem, klawiaturą, myszą i grafiką – zostały przeniesione i działają z wersją Emscripten także w przeglądarce, dzięki czemu można bezproblemowo przenosić całe gry utworzone w formacie SDL2. Jeśli przenosisz istniejący projekt, zapoznaj się z sekcją „Integracja z systemem kompilacji” w dokumentacji Emscripten.

Dla uproszczenia w tym poście skoncentruję się na przypadku z jednym plikiem i przetłumaczę przykładowy prostokąt na format 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ć je z Emscripten, musisz użyć interfejsu -s USE_SDL=2. Dzięki temu aplikacja Emscripten pobierze bibliotekę SDL2, która jest już wstępnie skompilowana do WebAssembly, i powiąże ją z Twoją główną aplikacją.

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

Po wczytaniu przykładu w przeglądarce pojawi się 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 im przydzielonych zasobów. Po drugie, strony internetowe nie są zamykane automatycznie po zakończeniu działania aplikacji, więc obraz w obszarze roboczym zostaje 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 to oczekiwanie 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 w celu wyjścia z pętli. Po zakończeniu pętli aplikacja wykona czyszczenie i zamknie się.

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

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

Jednak przykład nie działa już w internecie. Strona wygenerowana przez Emscripten blokuje 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? Zacytuję odpowiedź z artykułu „Using asynchronous web APIs from WebAssembly” (Używanie asynchronicznych internetowych interfejsów API z WebAssembly):

Krótka wersja polega na tym, że przeglądarka uruchamia wszystkie fragmenty kodu w nieskończonej pętli, pobierając je z kolejki jeden po drugim. Po wywołaniu zdarzenia przeglądarka umieszcza w kolejce odpowiedni moduł obsługi, a w kolejnej iteracji jest pobierany z kolejki i uruchamiany. Ten mechanizm umożliwia symulowanie współbieżności i wykonywanie wielu operacji równoległych przy użyciu tylko jednego wątku.

Pamiętaj, że podczas wykonywania niestandardowego kodu JavaScript (lub WebAssembly) pętla zdarzeń jest zablokowana […]

Poprzedni przykład uruchamia nieskończoną pętlę zdarzeń, a sam kod działa w innej nieskończonej pętli zdarzeń, która jest domyślnie udostępniana przez przeglądarkę. Wewnętrzna pętla nigdy nie przekazuje kontroli do zewnętrznej, więc przeglądarka nie ma możliwości przetwarzania zdarzeń zewnętrznych ani rysowania na stronie.

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

Odblokowywanie pętli zdarzeń za pomocą funkcji Asyncify

Po pierwsze, zgodnie z opisem w linku do artykułu możesz użyć narzędzia 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 mieć postać „uśpienia przez minimalny możliwy czas”, co wyraża się w interfejsie API emscripten_sleep(0). Dzięki umieszczeniu go w środku pętli mogę mieć pewność, że kontrola zostanie zwrócona do pętli zdarzeń przeglądarki w każdej iteracji, a strona pozostanie responsywna i będzie mogła 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 znowu działa prawidłowo w przeglądarce:

Wygenerowana strona HTML z zielonym prostokątem na czarnym kwadratowym obszarze roboczym.

Funkcja Asyncify może jednak mieć niezrozumiały rozmiar kodu. Jeśli jest używana tylko do pętli zdarzeń najwyższego poziomu w aplikacji, lepszym rozwiązaniem będzie 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.

Po pierwsze, należy wyodrębnić treść pętli zdarzeń do osobnej funkcji. Następnie funkcja emscripten_set_main_loop musi zostać wywołana z tą funkcją jako wywołaniem zwrotnym w pierwszym argumencie, FPS w drugim (0 dla natywnego interwału odświeżania) i wartość logiczna wskazująca, czy ma być symulowana pętla nieskończona (true) w trzecim:

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 w procesie sterowania są ręczne i uwzględniane w kodzie źródłowym, więc można je skompilować bez użycia 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 właściwa integracja pętli zdarzeń – za pomocą Asyncify lub emscripten_set_main_loop – sprawdzi się, jeśli zdecydujesz się dodać jakąkolwiek animację lub interaktywność.

Obsługa interakcji użytkowników

Na przykład dzięki kilku zmianom w ostatnim przykładzie możesz sprawić, że prostokąt będzie się przesuwał w odpowiedzi na zdarzenia wykonywane za pomocą klawiatury:

#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 eliminuje różnice między platformami i różne typy urządzeń multimedialnych w ramach jednego interfejsu API, ale jest to biblioteka na niskim poziomie. W szczególności w przypadku grafiki udostępnia interfejsy API do rysowania punktów, linii i prostokątów, ale wdrażanie bardziej złożonych kształtów i przekształceń pozostawia użytkownikowi.

SDL2_gfx to oddzielna biblioteka, która wypełnia tę lukę. Przykładowo prostokąt z przykładu powyżej może zostać zastąpiony 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 biblioteka SDL2_gfx musi być również połączona z aplikacją. Działa to podobnie jak w 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 tutaj wyniki uzyskane w systemie Linux:

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

W internecie:

Wygenerowana strona HTML z zielonym okręgiem na czarnym kwadratowym obszarze roboczym.

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