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 il porting della grafica da un sistema a un altro, incluso il porting del codice nativo a WebAssembly.

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

Canvas tramite Embind

Se stai avviando un nuovo progetto anziché provare a eseguirne il porting di uno esistente, potrebbe essere più semplice utilizzare l'API Canvas HTML tramite il sistema di binding Embind di Emscripten. 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, la tela viene inclusa e configurata per te. Semplifica la creazione di demo ed esempi semplici, ma nelle applicazioni più grandi è consigliabile includere il codice JavaScript e WebAssembly generato da Emscripten in una pagina HTML progettata da te.

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 la tela come segue:

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

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

Se utilizzi l'output dello script normale, 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 utilizzata per la grafica computerizzata. 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. Un paio di questi sono stati portati 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 accesso a basso livello ad audio, tastiera, mouse, joystick e hardware grafico 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 porting 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 con un solo file e tradurrò l'esempio di rettangolo precedente 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
}

Quando esegui 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 una pulizia adeguata 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, se lo stesso codice viene ricompilato in modo nativo con

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, viene attivato 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 di 300 x 300 pixel 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 dell'articolo "Utilizzare API web asincrone da WebAssembly":

La versione breve è che il browser esegue tutti i frammenti di codice in una sorta di loop infinito, estraendoli dalla coda uno per uno. 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.

L'aspetto importante da ricordare di questo meccanismo è che, durante l'esecuzione del codice JavaScript (o WebAssembly) personalizzato, 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.

Sbloccare il loop di eventi con Asyncify

Innanzitutto, come descritto nell'articolo collegato, puoi utilizzare Asyncify. Si tratta di una funzionalità di Emscripten che consente 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ò anche essere "sleep per il tempo minimo possibile", espresso 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 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
();
}

Ora questo codice 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 Emscripten che mostra un rettangolo verde su una tela quadrata nera.

Tuttavia, Asyncify può avere un overhead non trascurabile delle dimensioni del codice. Se viene utilizzato solo per un loop di eventi di primo livello nell'applicazione, un'opzione migliore può essere utilizzare la funzione emscripten_set_main_loop.

Sbloccare il loop di eventi con le API "loop principale"

emscripten_set_main_loop non richiede trasformazioni del compilatore per smontare e riavvolgere lo 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. Poi, emscripten_set_main_loop deve essere chiamato con la 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 dello stack nella funzione main, pertanto le variabili come window e renderer devono essere estratte in una struttura allocata nell'heap e il relativo puntatore deve essere passato tramite la variante emscripten_set_main_loop_arg dell'API oppure estratte 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 riportate nel codice sorgente, il codice può essere compilato 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 e l'evento SDL_QUIT, l'unico gestito nella funzione handle_events, venga comunque ignorato sul web.

Tuttavia, un'integrazione corretta 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, apportando alcune modifiche all'ultimo esempio, puoi far muovere 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 esegue l'astrazione delle differenze multipiattaforma e di vari tipi di dispositivi multimediali in un'unica API, ma è comunque una libreria di basso livello. In particolare per la grafica, sebbene fornisca API per disegnare punti, linee e rettangoli, l'implementazione di eventuali trasformazioni e forme più complesse è lasciata 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 è necessario collegare all'applicazione anche la libreria SDL2_gfx. L'operazione viene eseguita in modo 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

Ed ecco i risultati su Linux:

Una finestra quadrata di Linux 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 la documentazione generata automaticamente.