Dessiner sur un canevas dans Emscripten

Découvrez comment afficher des graphismes 2D sur le Web depuis WebAssembly avec Emscripten.

Les API disponibles pour le dessin graphique varient selon les systèmes d'exploitation. Les différences deviennent encore plus déroutantes lors de l'écriture d'un code multiplate-forme ou du portage de graphiques d'un système à un autre, y compris lors du portage de code natif vers WebAssembly.

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

Canvas via Embind

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

Pour comprendre comment utiliser Embind, consultez d'abord l'exemple de MDN suivant, qui trouve 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ération en C++ peut être effectuée 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 éléments 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 shell précédente, le canevas est inclus et configuré pour vous. Il facilite la création de démonstrations et d'exemples simples, mais dans les applications plus volumineuses, il est préférable d'inclure le JavaScript et WebAssembly générés 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 pour les autres propriétés de module, elle peut être définie 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 l'infographie. Lorsqu'il est utilisé dans Emscripten, il se charge de convertir le sous-ensemble d'opérations OpenGL compatible 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 se charger de l'émulation, mais vous devez activer cette option via les paramètres correspondants.

Vous pouvez utiliser OpenGL directement ou via des bibliothèques graphiques 2D et 3D de niveau supérieur. Quelques-unes d'entre elles ont été transférées sur le Web avec Emscripten. Dans cet article, je me concentre sur les graphismes 2D. Pour cela, la bibliothèque SDL2 est actuellement la bibliothèque préférée, car elle a été bien testée et est compatible avec le backend Emscripten officiellement en amont.

Dessiner un rectangle

Contenu de la section "À propos de SDL" sur le site Web officiel:

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

Toutes ces fonctionnalités (contrôle du son, du clavier, de la souris et des éléments graphiques) ont été portées et fonctionnent avec Emscripten sur le Web. Vous pouvez donc transférer facilement des jeux entièrement conçus avec SDL2. Si vous transférez un projet existant, consultez la section "Integrating with a build system" (Intégration avec un système de compilation) de la documentation Emscripten.

Pour plus de simplicité, dans cet article, je vais me concentrer sur un cas à fichier unique et traduire l'exemple 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
}

Pour effectuer l'association 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 dans WebAssembly, et de l'associer à votre application principale.

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

Une fois l'exemple chargé dans le navigateur, le rectangle vert que vous connaissez bien s'affiche:

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

Ce code présente toutefois quelques problèmes. Premièrement, les ressources allouées ne sont pas correctement nettoyées. Deuxièmement, sur le Web, les pages ne se ferment pas automatiquement lorsqu'une application a terminé son exécution, de sorte que l'image sur la toile est conservée. Toutefois, lorsque le même code est recompilé nativement avec

clang example.cpp -o example -lSDL2

et exécutée, la fenêtre créée clignote brièvement et se ferme immédiatement après la fermeture. L'utilisateur n'a donc pas la possibilité de voir l'image.

Intégrer une boucle d'événements

Un exemple plus complet et idiomatique nécessiterait d'attendre dans une boucle d'événements jusqu'à ce que l'utilisateur décide 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 que l'image a été dessinée dans une fenêtre, l'application attend dans une boucle où elle peut traiter les événements liés au clavier, à la souris et à d'autres utilisateurs. Lorsque l'utilisateur ferme la fenêtre, il déclenche un événement SDL_QUIT, qui est intercepté pour quitter la boucle. Une fois la boucle fermé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 avec un rectangle vert:

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

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

Page HTML générée par Emscripten avec en superposition une boîte de dialogue d &#39;erreur indiquant que la page ne répond pas, suggérant d&#39;attendre que la page devienne responsable ou de la quitter

Que s'est-il passé ? Je reprends la réponse de l'article Using asynchrone web APIs from WebAssembly (Utiliser des API Web asynchrones à partir de WebAssembly) :

Dans la version courte, le navigateur exécute tous les morceaux de code sous la forme d'une boucle infinie, en les retirant un par un de la file d'attente. Lorsqu'un événement est déclenché, le navigateur met le gestionnaire en file d'attente et, à l'itération de boucle suivante, 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.

Ce qu'il faut retenir de ce mécanisme, c'est que pendant que votre code JavaScript (ou WebAssembly) personnalisé s'exécute, 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, de sorte que le navigateur ne peut pas traiter les événements externes ni dessiner des éléments sur la page.

Il existe deux façons de résoudre ce problème.

Déblocage de la boucle d'événements avec Asyncify

Tout d'abord, comme indiqué 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 redonner le contrôle de la boucle d'événements et de réactiver le programme lorsqu'une opération asynchrone est terminée.

Cette opération asynchrone peut même être une "mise en veille le temps le plus court possible", exprimée à l'aide de 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 responsive 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

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

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

Toutefois, Asyncify peut entraîner une surcharge de code complexe. Si elle n'est utilisée que pour une boucle d'événements de premier niveau dans l'application, il est préférable d'utiliser la fonction emscripten_set_main_loop.

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

emscripten_set_main_loop ne nécessite aucune transformation de compilateur pour dérouler et revenir en arrière la pile d'appel, ce qui évite une surcharge du code. Cependant, en échange, il faudrait 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 s'il faut simuler une boucle infinie (true) dans le troisième:

emscripten_set_main_loop(callback, 0, true);

Le rappel créé n'aura pas accès aux variables de pile de la fonction main. Les variables telles que window et renderer doivent donc être extraites dans une structure allouée au tas 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 (pour plus de simplicité). Le résultat est légèrement plus difficile à suivre, mais il dessine le même rectangle que 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 apportées au flux de contrôle sont manuelles et se reflètent dans le code source, celui-ci peut à nouveau être compilé 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é avec succès sur le canevas bien que le code soit beaucoup plus simple, et l'événement SDL_QUIT, le seul géré dans la fonction handle_events, est de toute façon ignoré sur le Web.

Toutefois, une intégration correcte de la boucle d'événements (via Asyncify ou emscripten_set_main_loop) s'avère payante si vous décidez d'ajouter n'importe quel type d'animation ou d'interactivité.

Gérer les interactions des utilisateurs

Par exemple, en apportant quelques modifications au dernier exemple, vous pouvez faire en sorte que le rectangle se déplace 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 entre les plates-formes et les différents types de périphériques multimédias dans une seule API, mais il s'agit toujours d'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, la mise en œuvre 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, elle peut être utilisée pour remplacer un rectangle de 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();
}

À présent, la bibliothèque SDL2_gfx doit également être associée à l'application. Le fonctionnement est semblable à celui du 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

Et voici les résultats exécutés sous Linux:

Fenêtre Linux carrée avec un arrière-plan noir et un cercle vert

Et sur le Web:

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

Pour découvrir d'autres primitives graphiques, consultez la documentation générée automatiquement.