Dessiner sur un canevas dans Emscripten

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

Chaque système d'exploitation utilise des API différentes pour dessiner des graphismes. Les différences deviennent encore plus déroutantes lors de l'écriture d'un code multiplate-forme ou du portage des graphiques d'un système à un autre, y compris lors du portage de code natif vers WebAssembly.

Dans ce post, vous allez découvrir plusieurs méthodes permettant de dessiner des graphiques 2D sur l'élément canevas sur le 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 d'en transférer un existant, il peut être plus simple d'utiliser l'API Canvas HTML via le système de liaison d'Emscripten Embind. Embind vous permet d'effectuer des opérations directement sur des valeurs JavaScript arbitraires.

Pour comprendre comment utiliser Embind, commencez par consulter l'exemple 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 le 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 éléments compilés avec un serveur statique et charger l'exemple dans un navigateur:

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

Choisir l'élément 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. Cela facilite la création de démonstrations et d'exemples simples. Toutefois, dans les applications plus volumineuses, vous pouvez inclure le code 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 canevas stocké dans la propriété Module.canvas. Comme pour les autres propriétés du 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'elle est utilisée dans Emscripten, elle convertit le sous-ensemble d'opérations OpenGL compatibles 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 les émuler. Toutefois, vous devez activer cette fonctionnalité via les paramètres correspondants.

Vous pouvez utiliser OpenGL soit directement, soit via des bibliothèques graphiques 2D et 3D de niveau supérieur. Quelques-uns d'entre eux ont été transférés sur le Web avec Emscripten. Dans cet article, nous nous concentrons sur les graphismes 2D. C'est pourquoi SDL2 est actuellement la bibliothèque privilégiée, car elle a été testée avec soin et est officiellement compatible avec le backend Emscripten en amont.

Dessiner un rectangle

"À propos de SDL" sur le 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, clavier, souris, joystick et graphique via OpenGL et Direct3D.

Toutes les fonctionnalités (contrôle de l'audio, du clavier, de la souris et des graphismes) ont été transposées et fonctionnent avec Emscripten sur le Web. Vous pouvez ainsi transférer facilement des jeux entiers créés avec SDL2. Si vous transférez un projet existant, consultez la section "Integrating with a build system" (Intégrer avec un système de compilation) de la documentation d'Emscripten.

Pour plus de simplicité, dans cet article, nous allons nous 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
}

Pour effectuer l'association avec Emscripten, vous devez utiliser -s USE_SDL=2. Cela indiquera à Emscripten d'extraire la bibliothèque SDL2, déjà précompilée dans WebAssembly, et de la lier à 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 montrant un rectangle vert sur un canevas noir carré.

Ce code présente cependant quelques problèmes. Premièrement, il lui manque un nettoyage approprié des ressources allouées. Deuxièmement, sur le Web, les pages ne se ferment pas automatiquement à la fin de l'exécution d'une application. Par conséquent, l'image présente sur le canevas est conservée. Toutefois, lorsque le même code est recompilé en mode natif avec

clang example.cpp -o example -lSDL2

et s'exécute, la fenêtre créée clignote brièvement et se ferme immédiatement après la fermeture, de sorte que l'utilisateur n'a pas le temps de voir l'image.

Intégrer une boucle d'événements

Un exemple plus complet et idiomatique semble devoir 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 l'image dessinée dans une fenêtre, l'application attend dans une boucle, où elle peut traiter le clavier, la souris et d'autres événements utilisateur. 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 d'elle-même.

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

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

Toutefois, il 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 une mention &quot;Page non responsive&quot; en superposition boîte de dialogue d&#39;erreur suggérant d&#39;attendre que la page devienne responsable ou de la quitter

Que s'est-il passé ? Je citerai la réponse tirée de l'article Utiliser des API Web asynchrones depuis WebAssembly:

Dans sa version abrégée, le navigateur exécute tous les éléments de code dans une sorte de boucle infinie, en les extrayant un par un de la file d'attente. Lorsqu'un événement est déclenché, le navigateur met le gestionnaire correspondant en file d'attente. À l'itération 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 en parallèle à l'aide d'un seul thread.

La chose importante à retenir à propos de ce mécanisme 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, implicitement fournie par le navigateur. La boucle interne ne cède jamais le contrôle à la boucle externe, si bien que le navigateur n'a pas la possibilité de traiter les événements externes ni de 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 l'article associé, vous pouvez utiliser Asyncify. Il s'agit d'une fonctionnalité d'Emscripten qui permet de mettre en pause le programme C ou C++, redonner le contrôle à la boucle d'événements et réactiver le programme lorsqu'une opération asynchrone est terminée.

Ce type d'opération asynchrone peut même être "mis en veille pendant la durée minimale possible", exprimé 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 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();
}

Vous devez maintenant compiler ce code en activant Asyncify:

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 montrant un rectangle vert sur un canevas noir carré.

Cependant, Asyncify peut entraîner une surcharge du code non triviale. Si elle n'est utilisée que pour une boucle d'événements de niveau supérieur 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 "boucle principale" API

emscripten_set_main_loop ne nécessite aucune transformation de compilation pour dérouler et rembobiner la pile d'appel, ce qui évite une surcharge sur la taille du code. Cependant, en échange, 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 comme 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 qui vient d'être créé n'aura pas accès aux variables de pile dans la fonction main. Les variables telles que window et renderer doivent donc être extraites dans une structure allouée par le 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 (j'ai choisi cette deuxième option 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 sont reflétées dans le code source, il est possible de le compiler à nouveau sans la fonctionnalité Asyncify:

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

Cet exemple peut sembler inutile, car il fonctionne comme dans la première version, où le rectangle a bien été dessiné sur le canevas, malgré un code beaucoup plus simple, et où l'événement SDL_QUIT, le seul géré dans la fonction handle_events, est de toute façon ignoré sur le Web.

Cependant, une bonne intégration des boucles 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 déplacer 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 entre les plates-formes et les différents types d'appareils multimédias dans une seule API, mais il s'agit toujours d'une bibliothèque de bas niveau. Pour les graphismes, en particulier, bien qu'il fournisse des API pour dessiner des points, des lignes et des rectangles, l'implémentation de formes et transformations plus complexes est laissée à l'utilisateur.

SDL2_gfx est une bibliothèque distincte qui comble cette lacune. Elle peut par exemple servir à 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();
}

La bibliothèque SDL2_gfx doit maintenant être associée à l'application. La procédure est semblable à celle 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 noir carré.

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