Rysowanie na płótnie w Emscripten

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

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

Z tego posta dowiesz się, jak rysować grafikę 2D na elemencie canvas w internecie, korzystając z kodu w C lub C++ skompilowanego za pomocą 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. Opcja Embind umożliwia bezpośrednie wykonywanie działań na dowolnych wartościach JavaScript.

Aby zrozumieć, jak używać Embind, zapoznaj się najpierw z tym przykładem z MDN, który znajduje tag <canvas>. i narysuje na nim jakieś 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 wyświetlić skompilowane zasoby na serwerze statycznym i załadować przykładowy plik w przeglądarce:

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

Wybieranie elementu canvas

Jeśli użyjesz powłoki HTML wygenerowanej przez Emscripten z poprzednim poleceniem powłoki, obszar roboczy zostanie dołączony i skonfigurowany za Ciebie. Ułatwia on tworzenie prostych wersji demonstracyjnych i przykładów, ale w większych aplikacjach warto umieścić kod JavaScript wygenerowany przez Emscripten i WebAssembly na własnej stronie HTML.

Wygenerowany kod JavaScript oczekuje odnalezienia elementu canvas przechowywanego we właściwości Module.canvas. Tak jak w przypadku innych właściwości modułu, można ją ustawić podczas inicjowania.

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 międzyplatformowy interfejs API do grafiki komputerowej. Gdy jest używany w programie Emscripten, umożliwia konwersję obsługiwanego podzbioru operacji OpenGL do formatu WebGL. Jeśli Twoja aplikacja wymaga funkcji obsługiwanych w OpenGL ES 2.0 lub 3.0, ale nie w WebGL, Emscripten może się ich emulować. Musisz wyrazić na to zgodę w odpowiednich ustawieniach.

Z programu OpenGL możesz korzystać bezpośrednio lub przez biblioteki grafiki 2D i 3D wyższego poziomu. Kilka z nich zostało przeniesionych do sieci za pomocą aplikacji 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

„Informacje o SDL” na oficjalnej stronie:

Simple DirectMedia Layer to międzyplatformowa biblioteka programistyczna, która zapewnia łatwy 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 działają z wersją Emscripten także w przeglądarce, co pozwala 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 strona HTML z zielonym prostokątem na czarnym kwadratowym obszarze roboczym.

Jest jednak kilka problemów z tym kodem. 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. Jeśli jednak ten sam kod zostanie ponownie skompilowany natywnie za pomocą funkcji

clang example.cpp -o example -lSDL2

i wykonane, utworzone okno zamiga tylko na chwilę i zamknie się zaraz po zamknięciu, więc użytkownik nie będzie miał szansy zobaczyć obrazu.

Wprowadzanie 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 przetworzyć klawiaturę, mysz i inne zdarzenia użytkownika. Gdy użytkownik zamknie okno, wywoła zdarzenie SDL_QUIT, które zostanie przechwycone w celu wyjścia z pętli. Po zamknięciu pętli aplikacja przeprowadzi czyszczenie, a następnie zakończy działanie.

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 nałożonym komunikatem „Strona nie odpowiada” okno z komunikatem o błędzie z sugestią zamknięcia strony lub oczekiwania na przejęcie kontroli nad stroną

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 równoczesności i uruchamianie wielu równoległych operacji przy korzystaniu tylko z 1 wątku.

Ważne, aby pamiętać o tym mechanizmie, że podczas wykonywania niestandardowego kodu JavaScript (lub WebAssembly) pętla zdarzeń jest blokowana [...].

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. To funkcja Emscripten, która umożliwia „wstrzymanie” programu w C lub C++, przywrócić kontrolę nad pętlą zdarzeń i wybudzić program po zakończeniu pewnej operacji asynchronicznej.

Taka operacja asynchroniczna może mieć postać „uśpienia przez minimalny możliwy czas”, co wyraża się w interfejsie API emscripten_sleep(0). Umieszczenie go w środku pętli daje mi pewność, że element sterujący wróci do pętli zdarzeń przeglądarki przy każdej iteracji, a strona będzie reagowała 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();
}

Ten kod należy teraz skompilować z włączoną usługą Asyncify:

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

Aplikacja działa zgodnie z oczekiwaniami w internecie:

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.

Odblokowanie pętli zdarzeń z „główną pętlą”. Interfejsy API

emscripten_set_main_loop nie wymaga żadnych przekształceń kompilatora do rozwijania i przewijania stosu wywołań, co pozwala uniknąć nadmiarowego rozmiaru kodu. W zamian musisz jednak wprowadzić w kodzie dużo więcej ręcznych modyfikacji.

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 symulować pętlę nieskończoną (true) w trzecim:

emscripten_set_main_loop(callback, 0, true);

Nowo utworzone wywołanie zwrotne nie będzie miało dostępu do zmiennych stosu w funkcji main, dlatego zmienne takie jak window i renderer należy wyodrębnić do struktury przydzielanej na stercie, a jej wskaźnik przekazywany przez wariant interfejsu API emscripten_set_main_loop_arg lub do globalnych zmiennych static (dla uproszczenia wybrałem to ostatnie). Wynik jest nieco trudniejszy do zrobienia, ale zostanie utworzony ten sam prostokąt, co 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ą 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 się wydawać bezużyteczny, ponieważ działa tak samo jak pierwsza wersja, w której prostokąt został narysowany na obszarze roboczym, mimo że kod był znacznie prostszy. Z kolei zdarzenie SDL_QUIT, jedyne obsługiwane przez funkcję handle_events, i tak jest 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 trzeba połączyć również bibliotekę SDL2_gfx 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

Wyniki w Linuksie:

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 podstawowych elementów graficznych znajdziesz w dokumentach generowanych automatycznie.