Rysowanie na płótnie w Emscripten

Dowiedz się, jak renderować grafikę 2D w internecie przy użyciu WebAssembly w Emscripten.

Różne systemy operacyjne mają różne interfejsy API do rysowania. Różnice stają się jeszcze bardziej niejasne przy pisaniu kodu na wielu platformach lub przenoszeniu grafiki z jednego systemu do innego, w tym przy przenoszeniu kodu natywnego do WebAssembly.

W tym poście poznasz kilka metod rysowania grafiki 2D na elemencie canvas w internecie, korzystając z kodu C lub C++ skompilowanego w Emscripten.

Płótno za pomocą Embind

Jeśli rozpoczynasz nowy projekt, a nie próbujesz przenieść istniejący, najłatwiejszym rozwiązaniem może być użycie interfejsu HTML Canvas API przez system powiązań Embind firmy Emscripten. Embind umożliwia bezpośrednie działanie na dowolnych wartościach JavaScript.

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

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

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

Oto jak transliteracja do 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);
}

Łącząc ten kod, musisz przekazać --bind, aby włączyć Embind:

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

Następnie możesz wyświetlić skompilowane zasoby na serwerze statycznym i załadować przykład w przeglądarce:

Strona HTML wygenerowana przez emscripten przedstawiająca zielony prostokąt na czarnym obszarze roboczym.

Wybieranie elementu canvas

Gdy używasz powłoki HTML wygenerowanej przez Emscripten z poprzednim poleceniem powłoki, obszar roboczy jest uwzględniany i konfigurowany. Ułatwia on tworzenie prostych demonstracji i przykładów, ale w większych aplikacjach warto umieścić kod JavaScript i WebAssembly wygenerowane w ramach Emscripten na własnej stronie HTML.

Wygenerowany kod JavaScript powinien znaleźć element canvas zapisany we właściwości Module.canvas. Tak jak inne właściwości modułu, można go ustawić podczas inicjowania.

Jeśli używasz trybu ES6 (ustawiasz ś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 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 międzyplatformowy interfejs API do obsługi grafiki komputerowej. Gdy jest używany w Emscripten, obsługuje on konwersję obsługiwanego podzbioru operacji OpenGL do WebGL. Jeśli Twoja aplikacja wymaga funkcji obsługiwanych przez OpenGL ES 2.0 lub 3.0, ale nie w WebGL, Emscripten może je też obsługiwać, ale musisz włączyć tę funkcję w odpowiednich ustawieniach.

Możesz używać OpenGL bezpośrednio lub za pomocą bibliotek wyższego poziomu grafiki 2D i 3D. Kilka z nich zostało przeniesionych do internetu przy użyciu usługi Emscripten. W tym poście skupiam się na grafice 2D. Obecnie preferowana jest w tym przypadku biblioteka SDL2, która została dokładnie przetestowana i oficjalnie obsługuje backend Emscripten.

Rysowanie prostokąta

Sekcja „Informacje o SDL” na oficjalnej stronie zawiera te informacje:

Simple DirectMedia Layer to wieloplatformowa biblioteka programistyczna, która zapewnia niskopoziomowy dostęp do dźwięku, klawiatury, myszy, joysticka i sprzętu graficznego przez OpenGL i Direct3D.

Wszystkie te funkcje – sterowanie dźwiękiem, klawiaturą, myszą i grafiką – zostały przeniesione i obsługują platformę Emscripten również w internecie, dzięki czemu można bezproblemowo przenosić całe gry oparte na SDL2. Jeśli przenosisz istniejący projekt, zapoznaj się z sekcją „Integrating with a build system” (Integracja z systemem kompilacji) w dokumentacji Emscripten.

Dla uproszczenia w tym poście skupię się na pojedynczym pliku i przetłumaczę przykładowy prostokąt z wcześniejszym zapisem 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
}

Do łączenia z Emscripten należy używać języka -s USE_SDL=2. Dzięki temu Emscripten pobierze bibliotekę SDL2 wstępnie skompilowaną do WebAssembly i połączy ją z Twoją główną aplikacją.

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

Po załadowaniu przykładu w przeglądarce zostanie wyświetlony znany zielony prostokąt:

Strona HTML wygenerowana przez emscripten przedstawiająca zielony prostokąt na czarnym kwadratowym płótnie.

Jest jednak z tym kodem kilka problemów. Po pierwsze, brakuje w nim odpowiedniego uporządkowania przydzielonych zasobów. Po drugie, w internecie strony nie są zamykane automatycznie po zakończeniu wykonywania aplikacji, więc obraz na obszarze roboczym zostaje zachowany. Jeśli jednak ten sam kod zostanie skompilowany natywnie z funkcją

clang example.cpp -o example -lSDL2

utworzone okno mruga tylko na chwilę i zamknie natychmiast po zamknięciu, więc użytkownik nie ma szansy zobaczyć obrazu.

Integrowanie pętli zdarzeń

Bardziej kompleksowy i idiomatyczny przykład wygląda na 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 do okna aplikacja oczekuje w pętli, gdzie może przetworzyć klawiaturę, mysz i inne zdarzenia użytkownika. Gdy użytkownik zamknie okno, wywoła zdarzenie SDL_QUIT, które zostanie przechwycone w celu opuszczenia pętli. Po zakończeniu pętli aplikacja przeprowadza czyszczenie, a potem się zamyka.

Teraz kompilowanie 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.

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

Strona HTML wygenerowana przez emscripten z nałożonym komunikatem o błędzie „Strona nie odpowiada” sugerującą, że należy poczekać na wykrycie skutków działania strony lub ją zamknąć.

Co się stało? Zacytuję odpowiedź z artykułu „Korzystanie z 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, przenosząc je jeden po drugim z kolejki. Po wywołaniu jakiegoś zdarzenia przeglądarka doda odpowiedni moduł obsługi do kolejki, a w kolejnej jego powtórzeniu jest usuwany z kolejki i wykonywany. Ten mechanizm umożliwia symulowanie równoczesności i wykonywanie wielu równoległych operacji przy użyciu tylko jednego wątku.

Pamiętaj o tym, że podczas wykonywania niestandardowego kodu JavaScript (lub WebAssembly) pętla zdarzeń jest blokowana [...]

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

Problem ten można rozwiązać na dwa sposoby.

Odblokowywanie pętli zdarzeń za pomocą Asyncify

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

Taka operacja asynchroniczna może być nawet „uśpiona przez najkrótszy możliwy czas”, co można wyrazić za pomocą interfejsu API emscripten_sleep(0). Osadzę go w środku pętli, dzięki czemu element sterujący będzie po każdej iteracji przywracany do pętli zdarzeń przeglądarki, a strona pozostanie elastyczna i będzie obsługiwać dowolne 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();
}

Musisz teraz skompilować ten kod z włączoną usługą Asyncify:

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

Aplikacja znów działa w sieci zgodnie z oczekiwaniami:

Strona HTML wygenerowana przez emscripten przedstawiająca zielony prostokąt na czarnym kwadratowym płótnie.

Jednak Asyncify może mieć dodatkowe wymagania dotyczące rozmiaru kodu. Jeśli używasz jej tylko w pętli zdarzeń najwyższego poziomu w aplikacji, lepszym rozwiązaniem może być użycie funkcji emscripten_set_main_loop.

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

emscripten_set_main_loop nie wymaga przekształcania kompilatora do odwijania i przewijania stosu wywołań, dzięki czemu unikniesz narzutu rozmiaru kodu. Jednak wiąże się to z koniecznością ręcznych modyfikacji kodu.

Najpierw trzeba wyodrębnić treść pętli zdarzeń do osobnej funkcji. Następnie musisz wywołać funkcję emscripten_set_main_loop z tą funkcją jako wywołaniem zwrotnym w pierwszym argumencie, liczbą FPS w drugim argumencie (0 w przypadku natywnego odstępu odświeżania) i wartością logiczną wskazującą, czy symulować pętlę nieskończoną (true) w trzecim argumencie:

emscripten_set_main_loop(callback, 0, true);

Nowo utworzone wywołanie zwrotne nie ma dostępu do zmiennych stosu w funkcji main, dlatego zmienne takie jak window i renderer muszą zostać wyodrębnione do struktury przydzielanej stertą, a jej wskaźnik przekazany przez wariant interfejsu API emscripten_set_main_loop_arg lub wyodrębniony do globalnych zmiennych static (dla uproszczenia wybrałem tę drugą opcję). Wynik jest nieco trudniejszy do wykonania, ale rysuje się taki sam prostokąt, jak w poprzednim 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ą wprowadzane ręcznie i uwzględniane w kodzie źródłowym, więc można skompilować je ponownie bez funkcji Asyncify:

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

Ten przykład może się wydawać bezużyteczny, ponieważ działa tak samo jak w pierwszej wersji, w której prostokąt został narysowany w obszarze roboczym pomimo prostszego kodu, a zdarzenie SDL_QUIT – jedyne obsługiwane w funkcji handle_events – i tak jest ignorowane w sieci.

Jednak właściwa integracja pętli zdarzeń – przez Asyncify lub emscripten_set_main_loop – opłaca się, jeśli zdecydujesz się dodać jakąkolwiek animację lub interaktywność.

Obsługa interakcji użytkowników

Na przykład po wprowadzeniu kilku zmian w ostatnim przykładzie możesz sprawić, że prostokąt będzie się przesuwał w odpowiedzi na zdarzenia wykonywane z 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 przy użyciu SDL2_gfx

SDL2 eliminuje różnice między platformami i różne typy urządzeń multimedialnych w ramach jednego interfejsu API, ale nadal jest to biblioteka dość niskopoziomowa. W szczególności w przypadku grafiki udostępnia interfejsy API do rysowania punktów, linii i prostokątów, jednak użytkownik może wdrażać bardziej złożone kształty i przekształcenia.

SDL2_gfx to osobna biblioteka, która wypełnia tę lukę. Dzięki temu można na przykład zastąpić prostokąt w powyższym przykładzie kołem:

#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 trzeba połączyć z aplikacją bibliotekę SDL2_gfx. Robi się to podobnie 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

Oto wyniki wyświetlane w systemie Linux:

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

W internecie

Strona HTML wygenerowana przez emscripten z zielonym okręgiem na czarnym kwadratowym obszarze.

Więcej podstawowych elementów graficznych znajdziesz w dokumentach generowanych automatycznie.