Dessiner sur un canevas dans Emscripten

Découvrez comment afficher des graphiques 2D sur le Web à partir de WebAssembly avec Emscripten.

Les API de dessin graphique diffèrent selon les systèmes d'exploitation. Les différences deviennent encore plus confuses lorsque vous écrivez du code multiplate-forme ou que vous transférez des éléments graphiques d'un système à un autre, y compris lors du transfert de code natif vers WebAssembly.

Dans cet article, vous allez découvrir quelques méthodes permettant de dessiner des graphiques 2D dans l'élément canvas sur le Web à partir de code C ou C++ compilé avec Emscripten.

Canevas via Embind

Si vous commencez un nouveau projet plutôt que d'essayer de porter un projet existant, il peut être plus simple d'utiliser l'API Canvas HTML via le système de liaison Embind d'Emscripten. Embind vous permet d'opérer directement sur des valeurs JavaScript arbitraires.

Pour comprendre comment utiliser Embind, consultez d'abord l'exemple suivant de MDN, qui recherche un élément <canvas> et y dessine des formes.

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

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

Voici comment la translittérer en C++ avec 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);
}

Lorsque vous associez ce code, veillez à transmettre --bind pour activer Embind:

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

Vous pouvez ensuite diffuser les composants compilés avec un serveur statique et charger l'exemple dans un navigateur:

Page HTML générée par Emscripten affichant un rectangle vert sur un canevas noir.

Choisir l'élément de canevas

Lorsque vous utilisez le shell HTML généré par Emscripten avec la commande de shell précédente, le canevas est inclus et configuré pour vous. Cela facilite la création de démonstrations et d'exemples simples, mais dans les applications plus importantes, vous devez inclure le code JavaScript et WebAssembly généré par Emscripten dans une page HTML de votre propre conception.

Le code JavaScript généré s'attend à trouver l'élément de canevas stocké dans la propriété Module.canvas. Comme les autres propriétés de module, il peut être défini lors de l'initialisation.

Si vous utilisez le mode ES6 (en définissant la sortie sur un chemin d'accès avec une extension .mjs ou en utilisant le paramètre -s EXPORT_ES6), vous pouvez transmettre le canevas comme suit:

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

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

Si vous utilisez une sortie de script standard, vous devez déclarer l'objet Module avant de charger le fichier JavaScript généré par Emscripten:

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

OpenGL et SDL2

OpenGL est une API multiplate-forme populaire pour les graphismes informatiques. Lorsqu'il est utilisé dans Emscripten, il se charge de convertir le sous-ensemble compatible des opérations OpenGL en WebGL. Si votre application repose sur des fonctionnalités compatibles avec OpenGL ES 2.0 ou 3.0, mais pas avec WebGL, Emscripten peut également s'en charger, mais vous devez activer cette fonctionnalité via les paramètres correspondants.

Vous pouvez utiliser OpenGL directement ou via des bibliothèques graphiques 2D et 3D de niveau supérieur. Certains d'entre eux ont été convertis pour le Web avec Emscripten. Dans cet article, je me concentre sur les graphiques 2D. Pour cela, SDL2 est actuellement la bibliothèque privilégiée, car elle a été bien testée et est compatible avec le backend Emscripten officiellement en amont.

Dessiner un rectangle

La section "À propos de SDL" du site Web officiel indique:

Simple DirectMedia Layer est une bibliothèque de développement multiplate-forme conçue pour fournir un accès de bas niveau au matériel audio, au clavier, à la souris, au joystick et aux graphiques via OpenGL et Direct3D.

Toutes ces fonctionnalités (contrôle de l'audio, du clavier, de la souris et des graphiques) ont été portées et fonctionnent également avec Emscripten sur le Web. Vous pouvez ainsi porter des jeux entiers créés avec SDL2 sans trop de tracas. Si vous portez un projet existant, consultez la section Intégration à un système de compilation de la documentation Emscripten.

Par souci de simplicité, dans cet article, je vais me concentrer sur un cas à fichier unique et traduire l'exemple de rectangle précédent en 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
}

Lorsque vous associez des éléments avec Emscripten, vous devez utiliser -s USE_SDL=2. Cela indique à Emscripten de récupérer la bibliothèque SDL2, déjà précompilée en WebAssembly, et de l'associer à votre application principale.

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

Lorsque l'exemple est chargé dans le navigateur, le rectangle vert familier s'affiche:

Page HTML générée par Emscripten affichant un rectangle vert sur un canevas carré noir.

Ce code présente cependant quelques problèmes. Tout d'abord, il ne nettoie pas correctement les ressources allouées. Deuxièmement, sur le Web, les pages ne se ferment pas automatiquement lorsqu'une application a terminé son exécution. L'image du canevas est donc conservée. Toutefois, lorsque le même code est recompilé en mode natif avec

clang example.cpp -o example -lSDL2

et exécuté, la fenêtre créée ne clignote que brièvement et se ferme immédiatement à la sortie, de sorte que l'utilisateur n'a pas la possibilité de voir l'image.

Intégrer une boucle d'événements

Un exemple plus complet et plus idiomatique consisterait à attendre dans une boucle d'événements jusqu'à ce que l'utilisateur choisisse de quitter l'application:

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

Une fois l'image dessinée dans une fenêtre, l'application attend dans une boucle, où elle peut traiter les événements de clavier, de souris et d'autres événements utilisateur. Lorsque l'utilisateur ferme la fenêtre, il déclenche un événement SDL_QUIT, qui sera intercepté pour quitter la boucle. Une fois la boucle terminée, l'application effectue le nettoyage, puis se ferme.

La compilation de cet exemple sous Linux fonctionne comme prévu et affiche une fenêtre de 300 x 300 pixels avec un rectangle vert:

Fenêtre Linux carrée avec fond noir et rectangle vert.

Toutefois, l'exemple ne fonctionne plus sur le Web. La page générée par Emscripten se fige immédiatement lors du chargement et n'affiche jamais l'image affichée:

Page HTML générée par Emscripten superposée à une boîte de dialogue d&#39;erreur &quot;Page non réactive&quot; suggérant d&#39;attendre que la page devienne réactive ou de la quitter

Que s'est-il passé ? Je vais citer la réponse de l'article Utiliser des API Web asynchrones à partir de WebAssembly:

En résumé, le navigateur exécute tous les éléments de code dans une sorte de boucle infinie, en les retirant de la file d'attente un par un. Lorsqu'un événement est déclenché, le navigateur met en file d'attente le gestionnaire correspondant. Lors de l'itération suivante de la boucle, il est retiré de la file d'attente et exécuté. Ce mécanisme permet de simuler la simultanéité et d'exécuter de nombreuses opérations parallèles tout en n'utilisant qu'un seul thread.

Il est important de se rappeler que, pendant l'exécution de votre code JavaScript (ou WebAssembly) personnalisé, la boucle d'événements est bloquée. […]

L'exemple précédent exécute une boucle d'événements infinie, tandis que le code lui-même s'exécute dans une autre boucle d'événements infinie, fournie implicitement par le navigateur. La boucle interne ne cède jamais le contrôle à la boucle externe. Le navigateur n'a donc pas la possibilité de traiter les événements externes ni de dessiner des éléments sur la page.

Vous pouvez résoudre ce problème de deux manières.

Débloquer la boucle d'événements avec Asyncify

Tout d'abord, comme décrit dans cet article, vous pouvez utiliser Asyncify. Il s'agit d'une fonctionnalité Emscripten qui permet de "mettre en pause" le programme C ou C++, de rendre le contrôle à la boucle d'événements et de réveiller le programme lorsqu'une opération asynchrone est terminée.

Une telle opération asynchrone peut même être "mise en veille pendant la durée minimale possible", exprimée via l'API emscripten_sleep(0). En l'intégrant au milieu de la boucle, je peux m'assurer que le contrôle est renvoyé à la boucle d'événements du navigateur à chaque itération, et que la page reste réactive et peut gérer tous les événements:

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

Ce code doit maintenant être compilé avec Asyncify activé:

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

L'application fonctionne à nouveau comme prévu sur le Web:

Page HTML générée par Emscripten affichant un rectangle vert sur un canevas carré noir.

Toutefois, Asyncify peut entraîner des frais généraux de taille de code non négligeables. S'il n'est utilisé que pour une boucle d'événements de niveau supérieur dans l'application, il peut être préférable d'utiliser la fonction emscripten_set_main_loop.

Débloquer la boucle d'événements avec les API "boucle principale"

emscripten_set_main_loop ne nécessite aucune transformation du compilateur pour dérouler et rembobiner la pile d'appels, ce qui évite les frais généraux liés à la taille du code. En revanche, cela nécessite beaucoup plus de modifications manuelles du code.

Tout d'abord, le corps de la boucle d'événements doit être extrait dans une fonction distincte. Ensuite, emscripten_set_main_loop doit être appelé avec cette fonction en tant que rappel dans le premier argument, un FPS dans le deuxième argument (0 pour l'intervalle d'actualisation natif) et une valeur booléenne indiquant si la simulation d'une boucle infinie (true) doit être effectuée dans le troisième:

emscripten_set_main_loop(callback, 0, true);

Le rappel nouvellement créé n'aura aucun accès aux variables de pile dans la fonction main. Par conséquent, les variables telles que window et renderer doivent être extraites dans une struct allouée dans la pile de mémoire et son pointeur transmis via la variante emscripten_set_main_loop_arg de l'API, ou extraites dans des variables static globales (j'ai choisi la seconde option pour plus de simplicité). Le résultat est un peu plus difficile à suivre, mais il dessine le même rectangle que dans le dernier exemple:

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

Étant donné que toutes les modifications du flux de contrôle sont manuelles et reflétées dans le code source, il peut être compilé à nouveau sans la fonctionnalité Asyncify:

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

Cet exemple peut sembler inutile, car il ne fonctionne pas différemment de la première version, où le rectangle a été dessiné sur le canevas avec succès, même si le code est beaucoup plus simple, et que l'événement SDL_QUIT (le seul géré dans la fonction handle_events) est ignoré sur le Web.

Toutefois, une intégration appropriée de la boucle d'événements (via Asyncify ou emscripten_set_main_loop) est payante si vous décidez d'ajouter un type d'animation ou d'interactivité.

Gérer les interactions des utilisateurs

Par exemple, en apportant quelques modifications au dernier exemple, vous pouvez faire bouger le rectangle en réponse à des événements de clavier:

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

Dessiner d'autres formes avec SDL2_gfx

SDL2 élimine les différences multiplates-formes et les différents types d'appareils multimédias dans une seule API, mais il reste une bibliothèque de bas niveau. En particulier pour les graphiques, bien qu'il fournisse des API pour dessiner des points, des lignes et des rectangles, l'implémentation de formes et de transformations plus complexes est laissée à l'utilisateur.

SDL2_gfx est une bibliothèque distincte qui comble cette lacune. Par exemple, vous pouvez remplacer un rectangle dans l'exemple ci-dessus par un cercle:

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

La bibliothèque SDL2_gfx doit maintenant également être liée à l'application. Il se fait de la même manière que 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

Voici les résultats obtenus sous Linux:

Fenêtre Linux carrée avec fond noir et cercle vert.

Sur le Web:

Page HTML générée par Emscripten affichant un cercle vert sur un canevas carré noir.

Pour en savoir plus sur les primitives graphiques, consultez la documentation générée automatiquement.