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 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 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.

Canevas via Embind

Si vous démarrez 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'effectuer des opérations 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 é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 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, 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 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 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, 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 la 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 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 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
}

Lorsque vous associez votre application à 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 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 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 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&#39;image dessinée dans une fenêtre, l&#39;application attend dans une boucle, où elle peut traiter les événements de clavier, de souris et d&#39;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 terminée, l&#39;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 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 en superposition une boîte de dialogue d&#39;erreur &quot;Page non responsive&quot; (Page non réactive) 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&#39;un événement est déclenché, le navigateur met en file d&#39;attente le gestionnaire correspondant. Lors de l&#39;itération suivante de la boucle, il est retiré de la file d&#39;attente et exécuté. Ce mécanisme permet de simuler la simultanéité et d&#39;exécuter de nombreuses opérations parallèles en n&#39;utilisant qu&#39;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. Le navigateur n&#39;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éblocage de la boucle d'événements avec Asyncify

Tout d'abord, comme indiqué dans l'article associé, vous pouvez utiliser Asyncify. Il s&#39;agit d&#39;une fonctionnalité Emscripten qui permet de &quot;mettre en pause&quot; le programme C ou C++, de rendre le contrôle à la boucle d&#39;événements et de réveiller le programme lorsqu&#39;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&#39;intégrant au milieu de la boucle, je peux m&#39;assurer que le contrôle est renvoyé à la boucle d&#39;é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. emscripten_set_main_loop doit ensuite être appelé avec cette fonction en tant que rappel dans le premier argument, un FPS dans le deuxième argument (0 pour l'intervalle de rafraîchissement natif) et une valeur booléenne indiquant si une boucle infinie doit être simulée (true) dans le troisième :

emscripten_set_main_loop(callback, 0, true);

Le rappel nouvellement créé n'aura aucun accès aux variables de pile de 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 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.

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 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. 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. 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

Voici les résultats obtenus sous Linux :

Fenêtre Linux carrée avec un arrière-plan noir et un 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.