Disegno su canvas in Emscripten

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

Ingvar Stepanyan
Ingvar Stepanyan

Ogni sistema operativo utilizza API diverse per la grafica. Le differenze diventano ancora più confuse quando si scrive un codice multipiattaforma o si porta la grafica da un sistema all'altro, anche quando si esegue il trasferimento del codice nativo in WebAssembly.

In questo post imparerai un paio di metodi per disegnare grafica 2D sull'elemento canvas sul web a partire 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 associazioni di Emscripten Embind. Embind 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 disegna delle 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 abilitare Embind:

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

Dopodiché 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.

Scelta dell'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 demo ed esempi semplici, ma nelle applicazioni più grandi potresti voler includere il codice JavaScript e WebAssembly generati da Emscripten in una pagina HTML di tua progettazione.

Il codice JavaScript generato prevede di trovare l'elemento canvas memorizzato nella proprietà Module.canvas. Come altre proprietà dei moduli, 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 nel seguente modo:

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

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

Se utilizzi un output di script standard, 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 è una nota API multipiattaforma per la grafica per computer. Se utilizzato in Emscripten, si occupa di convertire il sottoinsieme di operazioni OpenGL supportato 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ò anche emulare queste funzionalità, ma devi attivarle tramite le impostazioni corrispondenti.

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

Disegno di un rettangolo

La sezione "Informazioni su SDL" del sito web ufficiale riporta:

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

Tutte queste funzionalità (controllo di audio, tastiera, mouse e grafica) sono state portate e funzionano con Emscripten anche sul web, in modo da poter trasferire interi giochi realizzati con SDL2 senza molti fastidi. Se vuoi trasferire un progetto esistente, consulta la sezione "Integrazione con un sistema di build" nella documentazione di Emscripten.

Per semplicità, in questo post mi soffermerò su una richiesta di un solo file e tradurrò l'esempio precedente del 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 potrà recuperare la libreria SDL2, già precompilata in WebAssembly, e collegarla alla tua applicazione principale.

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

Una volta caricato l'esempio 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ò un paio di problemi. In primo luogo, non ha un'adeguata pulizia delle risorse allocate. In secondo luogo, sul web le pagine non vengono chiuse automaticamente quando un'applicazione ha terminato l'esecuzione, perciò l'immagine sul canvas viene mantenuta. Tuttavia, quando lo stesso codice viene ricompilato in modo nativo con

clang example.cpp -o example -lSDL2

ed eseguito, la finestra creata lampeggerà solo brevemente e si chiuderà subito 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 dovrebbe attendere in un loop di eventi fino a quando l'utente 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 su una finestra, l'applicazione attende in un loop, dove può elaborare la tastiera, il mouse e altri eventi utente. Quando l'utente chiude la finestra, attiva un evento SDL_QUIT, che viene intercettato per uscire dal loop. Alla chiusura del loop, l'applicazione esegue la pulizia e quindi si chiude.

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

Una finestra Linux quadrata 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 con sovrapposta una finestra di dialogo di errore &quot;La pagina non risponde&quot; che suggerisce di attendere che la pagina diventi responsabile o di uscire dalla pagina.

Che cosa è successo? Riporto la risposta all'articolo "Utilizzare le API web asincrone da WebAssembly":

In breve, il browser esegue tutte le parti di codice in una sorta di loop infinito, prendendole dalla coda una per una. Quando viene attivato un evento, il browser mette in coda il gestore corrispondente e all'iterazione del loop successiva viene rimosso dalla coda ed eseguito. Questo meccanismo consente di simulare la contemporaneità ed eseguire molte operazioni parallele utilizzando un solo thread.

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

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

Esistono due modi per risolvere questo 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 al termine di un'operazione asincrona.

Questa operazione asincrona può essere anche "sleep per il tempo minimo possibile", espressa tramite l'API emscripten_sleep(0). Incorporandolo nel mezzo del loop, posso garantire che il controllo venga restituito al loop di eventi del browser a ogni iterazione e 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

E l'applicazione riprende nuovamente a funzionare come previsto sul web:

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

Tuttavia, Asyncify può avere dimensioni di codice non banali. Se viene utilizzata solo per un loop di eventi di primo livello nell'applicazione, una soluzione migliore è quella di utilizzare la funzione emscripten_set_main_loop.

Sblocco del loop di eventi con le API "main loop"

emscripten_set_main_loop non richiede alcuna trasformazione del compilatore per l'annullamento e il riavvolgimento dello stack di chiamate, evitando così 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, emscripten_set_main_loop deve essere chiamato 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 un loop 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 le variabili come window e renderer devono essere estratte in uno struct assegnato dall'heap e il relativo puntatore trasmesso tramite la variante emscripten_set_main_loop_arg dell'API oppure estratte nelle 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 si riflettono nel codice sorgente, può essere compilata di nuovo senza la funzionalità Asyncify:

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

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

Tuttavia, una corretta integrazione del loop di eventi, tramite Asyncify o emscripten_set_main_loop, ripaga 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 si tratta comunque di una libreria di basso livello. In particolare per la grafica, sebbene fornisca API per disegnare punti, linee e rettangoli, l'implementazione di forme e trasformazioni più complesse rimane all'utente.

SDL2_gfx è una libreria separata che colma questa lacuna. Ad esempio, può essere utilizzato per sostituire un rettangolo nell'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 anche la libreria SDL2_gfx deve essere collegata all'applicazione. La procedura è simile a quella per 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 eseguiti su Linux:

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

E sul web:

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

Per altre primitive grafiche, consulta i documenti generati automaticamente.