Disegno su canvas in Emscripten

Scopri come eseguire il rendering di grafica 2D sul web da WebAssembly con Emscripten.

Ingvar Stepanyan
Ingvar Stepanyan

I diversi sistemi operativi hanno API diverse per il disegno di elementi grafici. Le differenze diventano ancora più confuse quando si scrive un codice multipiattaforma o si esegue la portabilità della grafica da un sistema a un altro, anche durante la portabilità del codice nativo in WebAssembly.

In questo post imparerai un paio di metodi per disegnare grafica 2D sull'elemento canvas sul web dal codice C o C++ compilato con Emscripten.

Canvas tramite Embind

Se stai iniziando un nuovo progetto anziché provare a portarne uno esistente, potrebbe essere più semplice utilizzare l'API Canvas HTML tramite il sistema di associazione di Emscripten Embind. Embind ti consente di operare direttamente su valori JavaScript arbitrari.

Per capire come utilizzare Embind, dai un'occhiata al seguente esempio di MDN che trova un elemento <canvas> e vi disegna alcune forme

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

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

Ecco come può essere traslitterato in C++ con 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);
}

Quando colleghi questo codice, assicurati di passare --bind per attivare Embind:

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

Poi puoi pubblicare gli asset compilati con un server statico e caricare l'esempio in un browser:

Pagina HTML generata da Emscripten che mostra un rettangolo verde su una tela nera.

Scegliere l'elemento canvas

Quando utilizzi la shell HTML generata da Emscripten con il comando shell precedente, il canvas viene incluso e configurato automaticamente. Semplifica la creazione di semplici demo ed esempi, ma nelle applicazioni più grandi potresti includere il codice JavaScript generato da Emscripten e WebAssembly in una pagina HTML del tuo design.

Il codice JavaScript generato si aspetta di trovare l'elemento canvas memorizzato nella proprietà Module.canvas. Come le altre proprietà del modulo, può essere impostata durante l'inizializzazione.

Se utilizzi la modalità ES6 (impostando l'output su un percorso con un'estensione .mjs o utilizzando l'impostazione -s EXPORT_ES6), puoi passare il canvas in questo modo:

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

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

Se utilizzi un output di script regolare, devi dichiarare l'oggetto Module prima di caricare il file JavaScript generato da Emscripten:

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

OpenGL e SDL2

OpenGL è un'API multipiattaforma molto diffusa per la computer grafica. Se utilizzato in Emscripten, si occuperà di convertire il sottoinsieme supportato delle operazioni OpenGL in WebGL. Se la tua applicazione si basa su funzionalità supportate in OpenGL ES 2.0 o 3.0, ma non in WebGL, Emscripten può occuparsi anche di emularle, ma devi attivarle tramite le impostazioni corrispondenti.

Puoi utilizzare OpenGL direttamente o tramite librerie grafiche 2D e 3D di livello superiore. Alcuni di questi sono stati trasferiti sul web con Emscripten. In questo post mi concentro sulla grafica 2D e, per questo, SDL2 è attualmente la libreria preferita perché è stata ben testata e supporta il backend Emscripten ufficialmente in upstream.

Disegno di un rettangolo

Nella sezione "Informazioni su SDL" del sito web ufficiale è riportato quanto segue:

Simple DirectMedia Layer è una libreria di sviluppo multipiattaforma progettata per fornire un accesso di basso livello a hardware di audio, tastiera, mouse, joystick e grafica tramite OpenGL e Direct3D.

Tutte queste funzionalità, che controllano audio, tastiera, mouse e grafica, sono state trasferite e funzionano con Emscripten anche sul web, quindi puoi trasferire interi giochi creati con SDL2 senza troppi problemi. Se stai eseguendo il trasferimento di un progetto esistente, consulta la sezione "Integrazione con un sistema di compilazione" della documentazione di Emscripten.

Per semplicità, in questo post mi concentrerò su un caso d'uso di un solo file e tradurrò il precedente esempio di rettangolo 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
}

Per il collegamento con Emscripten, devi utilizzare -s USE_SDL=2. In questo modo, Emscripten recupererà la libreria SDL2, già precompilata in WebAssembly, e la collegherà all'applicazione principale.

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

Quando l'esempio viene caricato nel browser, viene visualizzato il familiare rettangolo verde:

Pagina HTML generata da Emscripten che mostra un rettangolo verde su una tela quadrata nera.

Questo codice presenta però alcuni problemi. Innanzitutto, manca un'adeguata pulizia delle risorse allocate. In secondo luogo, sul web le pagine non vengono chiuse automaticamente al termine dell'esecuzione di un'applicazione, quindi l'immagine sulla tela viene conservata. Tuttavia, quando lo stesso codice viene ricompilato in modo nativo

clang example.cpp -o example -lSDL2

e viene eseguita, la finestra creata lampeggia solo brevemente e si chiude immediatamente all'uscita, quindi l'utente non ha la possibilità di vedere l'immagine.

Integrazione di un loop di eventi

Un esempio più completo e idiomatico potrebbe essere necessario attendere in un ciclo di eventi finché l'utente non sceglie di uscire dall'applicazione:

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

Dopo che l'immagine è stata disegnata in una finestra, l'applicazione attende in un ciclo, dove può elaborare eventi della tastiera, del mouse e di altri utenti. Quando l'utente chiude la finestra, attiverà un evento SDL_QUIT, che verrà intercettato per uscire dal loop. Al termine del loop, l'applicazione eseguirà la pulizia e poi uscirà.

Ora la compilazione di questo esempio su Linux funziona come previsto e mostra una finestra 300 x 300 con un rettangolo verde:

Una finestra quadrata di Linux con sfondo nero e un rettangolo verde.

Tuttavia, l'esempio non funziona più sul web. La pagina generata da Emscripten si blocca immediatamente durante il caricamento e non mostra mai l'immagine visualizzata:

Pagina HTML generata da Emscripten sovrapposta a una finestra di dialogo di errore &quot;Pagina non risponde&quot; che suggerisce di attendere che la pagina diventi responsabile o di uscirne

Che cosa è successo? Cito la risposta dall'articolo "Using asinc web APIs from WebAssembly":

Nella versione breve, il browser esegue tutte le porzioni di codice in una sorta di loop infinito, prendendole dalla coda uno alla volta. Quando viene attivato un evento, il browser mette in coda l'handler corrispondente e nell'iterazione successiva del ciclo lo estrae dalla coda ed esegue. Questo meccanismo consente di simulare la concorrenza ed eseguire molte operazioni in parallelo utilizzando un solo thread.

La cosa importante da ricordare su questo meccanismo è che, mentre il tuo codice JavaScript (o WebAssembly) personalizzato viene eseguito, il loop di eventi è bloccato [...]

L'esempio precedente esegue un ciclo di eventi infinito, mentre il codice stesso viene eseguito all'interno di un altro ciclo di eventi infinito, fornito implicitamente dal browser. Il loop interno non cede mai il controllo a quello esterno, quindi il browser non ha la possibilità di elaborare eventi esterni o disegnare elementi nella pagina.

Esistono due modi per risolvere il problema.

Sblocco del loop di eventi con Asyncify

Innanzitutto, come descritto nell'articolo collegato, puoi utilizzare Asyncify. È una funzionalità Emscripten che permette di "mettere in pausa" il programma C o C++, restituire il controllo al loop di eventi e riattivare il programma quando sono terminate alcune operazioni asincrone.

L'operazione asincrona può essere anche "in sospensione per il tempo minimo possibile", espressa tramite l'API emscripten_sleep(0). Incorporandolo nel mezzo del loop, posso assicurarmi che il controllo venga restituito al loop di eventi del browser a ogni iterazione, che la pagina rimanga reattiva e possa gestire qualsiasi evento:

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

Questo codice ora deve essere compilato con Asyncify abilitato:

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

L&#39;applicazione funziona di nuovo come previsto sul web:

Pagina HTML generata con un pennarello che mostra un rettangolo verde su una tela quadrata nera.

Tuttavia, Asyncify può avere un overhead per le dimensioni del codice non banali. Se viene utilizzata solo per un loop di eventi di primo livello nell'applicazione, una soluzione migliore può essere l'uso della funzione emscripten_set_main_loop.

Sbloccare il loop di eventi con le API &quot;loop principale&quot;

emscripten_set_main_loop non richiede trasformazioni del compilatore per svolgere e riavvolgere lo stack di chiamate, evitando in questo modo il sovraccarico delle dimensioni del codice. Tuttavia, in cambio, richiede molte più modifiche manuali al codice.

Innanzitutto, il corpo del loop di eventi deve essere estratto in una funzione separata. Quindi, devi chiamare emscripten_set_main_loop con questa funzione come callback nel primo argomento, un FPS nel secondo argomento (0 per l'intervallo di aggiornamento nativo) e un valore booleano che indica se simulare il ciclo infinito (true) nel terzo:

emscripten_set_main_loop(callback, 0, true);

Il callback appena creato non avrà accesso alle variabili stack nella funzione main, quindi variabili come window e renderer devono essere estratte in uno struct allocato all'heap e il relativo puntatore passato tramite la variante emscripten_set_main_loop_arg dell'API o estratto in variabili static globali (ho scelto quest'ultima per semplicità). Il risultato è leggermente più difficile da seguire, ma disegna lo stesso rettangolo dell'ultimo esempio:

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

Poiché tutte le modifiche al flusso di controllo sono manuali e riflesse nel codice sorgente, può essere nuovamente compilato senza la funzionalità Asyncify:

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

Questo esempio potrebbe sembrare inutile, perché non funziona diversamente dalla prima versione, in cui il rettangolo è stato disegnato correttamente sul canvas nonostante il codice sia molto più semplice, e l'evento SDL_QUIT, l'unico gestito nella funzione handle_events, viene comunque ignorato sul web.

Tuttavia, la corretta integrazione del loop di eventi, tramite Asyncify o emscripten_set_main_loop, è utile se decidi di aggiungere qualsiasi tipo di animazione o interattività.

Gestione delle interazioni degli utenti

Ad esempio, con alcune modifiche all'ultimo esempio, puoi far spostare il rettangolo in risposta agli eventi della tastiera:

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

Disegno di altre forme con SDL2_gfx

SDL2 elimina le differenze multipiattaforma e i vari tipi di dispositivi multimediali in un'unica API, ma è comunque una libreria di basso livello. In particolare per la grafica, mentre fornisce API per disegnare punti, linee e rettangoli, l'implementazione di forme e trasformazioni più complesse è lasciata all'utente.

SDL2_gfx è una libreria separata che colma questa lacuna. Ad esempio, può essere utilizzato per sostituire un rettangolo nell&#39;esempio precedente con un cerchio:

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

Ora è necessario collegare all&#39;applicazione anche la libreria SDL2_gfx. Si tratta di una procedura simile a 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

Ecco i risultati in esecuzione su Linux:

Una finestra quadrata di Linux con sfondo nero e un cerchio verde.

E sul web:

Pagina HTML generata da un pennarello che mostra un cerchio verde su una tela quadrata nera.

Per altre primitive grafiche, consulta la documentazione generata automaticamente.