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 dispone di API differenti per la grafica. 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 consente di operare direttamente su valori JavaScript arbitrari.

Per capire come utilizzare Embind, dai un'occhiata al seguente esempio da MDN che trova un <canvas> su un elemento e disegna su di esso 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 abilitare Embind:

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

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

Pagina HTML generata da un pennarello 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 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 prevede di trovare l'elemento canvas memorizzato nella proprietà Module.canvas. Come le 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 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 è una nota API multipiattaforma per la grafica computerizzata. Se utilizzato in Emscripten, si occuperà di convertire il sottoinsieme supportato di 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ò emulare anche queste, ma dovrai attivare l'opzione 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 concentrerò sulla grafica 2D e per questo SDL2 è attualmente la libreria preferita perché è stato ben collaudato e supporta ufficialmente il backend Emscripten a monte.

Disegno di un rettangolo

"Informazioni su SDL" sul sito web ufficiale recita:

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à (il controllo di audio, tastiera, mouse e grafica) sono state trasferite e funzionano anche con Emscripten sul web per consentirti di trasferire interi giochi sviluppati con SDL2 senza troppi complicazioni. 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. Questo indicherà a Emscripten di recuperare la libreria SDL2, già precompilata in WebAssembly, e di collegarla alla tua 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 un pennarello che mostra un rettangolo verde su una tela quadrata nera.

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

clang example.cpp -o example -lSDL2

la finestra creata lampeggerà solo brevemente e si chiuderà immediatamente all'uscita, in modo che l'utente non abbia la possibilità di vedere l'immagine.

Integrazione di un loop di eventi

Un esempio più completo e idiomatico potrebbe dover attendere in un loop 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 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, attiverà un evento SDL_QUIT, che verrà intercettato per uscire dal loop. Una volta terminato il loop, l'applicazione esegue la pulizia e poi si chiuderà.

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 sottoposta a rendering:

Pagina HTML generata tramite Emscripten con sovrapposta il messaggio &quot;La pagina non risponde&quot; finestra di dialogo di errore che suggerisce di attendere che la pagina diventi responsabile o di uscire dalla pagina

Che cosa è successo? Cito la risposta dall'articolo "Using as asincrono 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 il gestore corrispondente e, nella successiva iterazione di loop, viene rimosso dalla coda ed eseguito. Questo meccanismo consente di simulare la contemporaneità e di eseguire molte operazioni parallele 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 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 cede mai il controllo a quello esterno, quindi il browser non ha la possibilità di elaborare eventi esterni o di disegnare elementi nella pagina.

Puoi risolvere il problema in due modi.

Sblocco del loop di eventi con Asyncify

Innanzitutto, come descritto nell'articolo collegato, puoi utilizzare Asyncify. è una funzionalità di 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.

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'applicazione funziona di nuovo come previsto sul web:

Pagina HTML generata da 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.

Sblocco del loop di eventi con il "loop principale" API

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, sono necessarie 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 di 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, è più efficace 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 utilizzata 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 nell'applicazione. 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 Linux con sfondo nero e un cerchio verde.

E sul web:

Pagina HTML generata da un pennarello che mostra un cerchio verde su un canvas quadrato nero.

Per altre primitive grafiche, consulta i documenti generati automaticamente.