Cómo dibujar a lienzo en Emscripten

Aprende a renderizar gráficos 2D en la Web de WebAssembly con Emscripten.

Los diferentes sistemas operativos tienen distintas APIs para dibujar gráficos. Las diferencias se vuelven aún más confusas cuando se escribe un código multiplataforma o se transfieren gráficos de un sistema a otro, incluso cuando se transfiere código nativo a WebAssembly.

En esta publicación, aprenderás un par de métodos para dibujar gráficos 2D en el elemento lienzo en la Web a partir de código C o C++ compilado con Emscripten.

Lienzo en Embind

Si estás comenzando un proyecto nuevo en lugar de intentar portar uno existente, podría ser más fácil usar la API de Canvas de HTML a través del sistema de vinculación de Emscripten Embind. Embind te permite operar directamente en valores arbitrarios de JavaScript.

Para comprender cómo utilizar Embind, primero observa el siguiente ejemplo de MDN donde se encuentra un <lienzo> y dibuja algunas formas sobre él

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

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

A continuación, te mostramos cómo se puede transliterar a 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);
}

Cuando vincules este código, asegúrate de pasar --bind para habilitar Embind:

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

Luego, puedes publicar los recursos compilados con un servidor estático y cargar el ejemplo en un navegador:

Página HTML con guiones gráficos que muestra un rectángulo verde sobre un lienzo negro.

Elige el elemento de lienzo

Cuando usas la shell de HTML generada por Emscripten con el comando de shell anterior, se incluye el lienzo y lo configuras. Facilita la compilación de demostraciones y ejemplos simples, pero en aplicaciones más grandes puedes incluir JavaScript y WebAssembly generados por Emscripten en una página HTML de tu propio diseño.

El código JavaScript generado espera encontrar el elemento de lienzo almacenado en la propiedad Module.canvas. Al igual que otras propiedades del módulo, se puede configurar durante la inicialización.

Si usas el modo ES6 (configurando el resultado en una ruta de acceso con una extensión .mjs o con el parámetro de configuración -s EXPORT_ES6), puedes pasar el lienzo de la siguiente manera:

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

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

Si usas resultados de secuencias de comandos normales, debes declarar el objeto Module antes de cargar el archivo JavaScript generado por Emscripten:

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

OpenGL y SDL2

OpenGL es una API multiplataforma popular para gráficos de computadora. Cuando se usa en Emscripten, se encarga de convertir el subconjunto compatible de operaciones de OpenGL a WebGL. Si tu aplicación depende de funciones compatibles con OpenGL ES 2.0 o 3.0, pero no en WebGL, Emscripten también puede encargarse de emularlas, pero debes habilitar la opción mediante la configuración correspondiente.

Puedes usar OpenGL directamente o a través de bibliotecas de gráficos 2D y 3D de nivel superior. Algunas de ellas se han transferido a la Web con Emscripten. En esta publicación, nos enfocaremos en los gráficos 2D. Para ello, SDL2 es actualmente la biblioteca preferida porque ha sido probada y es compatible oficialmente con el backend de Emscripten.

Dibujar un rectángulo

“Acerca de SDL” en el sitio web oficial dice:

DirectMedia Layer simple es una biblioteca de desarrollo multiplataforma diseñada para proporcionar acceso de bajo nivel a hardware de audio, teclado, mouse, joystick y gráficos a través de OpenGL y Direct3D.

Todas esas funciones (control de audio, teclado, mouse y gráficos) también se trasladaron y funcionan con Emscripten en la Web para que puedas transferir juegos completos compilados con SDL2 sin complicaciones. Si vas a portar un proyecto existente, consulta la sección "Integración con un sistema de compilación" de los documentos de Emscripten.

Para simplificar, en esta publicación me enfocaré en un caso de un solo archivo y traduciré el ejemplo anterior de rectángulo a 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
}

Cuando vincules con Emscripten, debes usar -s USE_SDL=2. Esto le indicará a Emscripten que recupere la biblioteca SDL2, ya compilada previamente en WebAssembly, y que la vincule con su aplicación principal.

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

Cuando el ejemplo se cargue en el navegador, verás el conocido rectángulo verde:

Página HTML con formato emscriptor que muestra un rectángulo verde sobre un lienzo cuadrado negro.

Sin embargo, este código tiene algunos problemas. En primer lugar, carece de una limpieza adecuada de los recursos asignados. En segundo lugar, en la Web, las páginas no se cierran automáticamente cuando una aplicación finaliza su ejecución, por lo que se conserva la imagen en el lienzo. Sin embargo, cuando el mismo código se vuelve a compilar de forma nativa con

clang example.cpp -o example -lSDL2

y ejecutada, la ventana creada solo parpadea brevemente y se cerrará inmediatamente al salir, para que el usuario no tenga la oportunidad de ver la imagen.

Cómo integrar un bucle de eventos

Un ejemplo más completo e idiomático necesitaría esperar en un bucle de eventos hasta que el usuario elija salir de la aplicación:

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

Después de que la imagen se dibujó en una ventana, la aplicación espera en un bucle, en el que puede procesar el teclado, el mouse y otros eventos de usuario. Cuando el usuario cierre la ventana, se activará un evento SDL_QUIT, que se interceptará para salir del bucle. Cuando se cierra el bucle, la aplicación realiza la limpieza y, luego, sale de sí misma.

Ahora, la compilación de este ejemplo en Linux funciona como se espera y muestra una ventana de 300 por 300 con un rectángulo verde:

Una ventana cuadrada de Linux con fondo negro y un rectángulo verde.

Sin embargo, el ejemplo ya no funciona en la Web. La página generada por Emscripten se bloquea inmediatamente durante la carga y nunca muestra la imagen renderizada:

Página HTML generada con una secuencia de comandos superpuesta con un mensaje que indica que la página no responde Diálogo de error que sugiere esperar a que la página se vuelva responsable o salir de ella

¿Qué pasó? Citaré la respuesta del artículo "Using asíncrono web APIs from WebAssembly" (Usa las APIs web asíncronas desde WebAssembly):

La versión corta es que el navegador ejecuta todos los fragmentos de código en una especie de bucle infinito, sacándolos de la cola uno por uno. Cuando se activa algún evento, el navegador pone en cola el controlador correspondiente y, en la siguiente iteración de bucle, se quita de la cola y se ejecuta. Este mecanismo permite simular la simultaneidad y ejecutar muchas operaciones paralelas mientras se usa un solo subproceso.

Lo importante que debes recordar sobre este mecanismo es que, mientras se ejecuta el código personalizado de JavaScript (o WebAssembly), el bucle de eventos se bloquea [...]

En el ejemplo anterior, se ejecuta un bucle de eventos infinitos, mientras que el código se ejecuta dentro de otro bucle de eventos infinitos proporcionado de forma implícita por el navegador. El bucle interno nunca cede el control al exterior, por lo que el navegador no tiene la oportunidad de procesar eventos externos ni dibujar elementos en la página.

Existen dos maneras de solucionar este problema.

Cómo desbloquear el bucle de eventos con Asyncify

Primero, como se describe en el artículo vinculado, puedes usar Asyncify. Es una función de Emscripten que permite “pausar” al programa de C o C++, devolverle el control al bucle de eventos y activar el programa cuando haya finalizado alguna operación asíncrona.

Esta operación asíncrona puede incluso "suspenderse por el mínimo tiempo posible" que se expresa a través de la API de emscripten_sleep(0). Al incorporarlo en el medio del bucle, puedo asegurarme de que el control se muestre en el bucle de eventos del navegador en cada iteración y de que la página siga siendo responsiva y pueda controlar cualquier 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();
}

Ahora, debes compilar este código con Asyncify habilitado:

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

Y la aplicación vuelve a funcionar como se espera en la Web:

Página HTML con formato emscriptor que muestra un rectángulo verde sobre un lienzo cuadrado negro.

Sin embargo, Asyncify puede tener una sobrecarga de tamaño de código importante. Si solo se usa para un bucle de eventos de nivel superior en la aplicación, una mejor opción puede ser usar la función emscripten_set_main_loop.

Desbloquea el bucle de eventos con “bucle principal” APIs

emscripten_set_main_loop no requiere ninguna transformación del compilador para desenrollar y retroceder la pila de llamadas y, de esa manera, evita la sobrecarga del tamaño del código. Sin embargo, a cambio, se requieren muchas más modificaciones manuales en el código.

Primero, el cuerpo del bucle de eventos debe extraerse en una función separada. Luego, se debe llamar a emscripten_set_main_loop con esa función como devolución de llamada en el primer argumento, un FPS en el segundo (0 para el intervalo de actualización nativo) y un valor booleano que indique si se debe simular un bucle infinito (true) en el tercero:

emscripten_set_main_loop(callback, 0, true);

La devolución de llamada recién creada no tendrá acceso a las variables de la pila en la función main, por lo que las variables como window y renderer deben extraerse en un struct asignado de montón y pasar su puntero a través de la variante emscripten_set_main_loop_arg de la API, o extraerse en variables static globales (elegí esta última por simplicidad). El resultado es un poco más difícil de seguir, pero dibuja el mismo rectángulo que el último ejemplo:

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

Dado que todos los cambios en el flujo de control son manuales y se reflejan en el código fuente, se pueden volver a compilar sin la función Asyncify nuevamente:

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

Este ejemplo puede parecer inútil, ya que no funciona de manera diferente a la primera versión, en la que el rectángulo se dibujó en lienzo con éxito a pesar de que el código era mucho más simple y el evento SDL_QUIT, el único que se maneja en la función handle_events, se ignora en la Web de todos modos.

Sin embargo, la integración adecuada del bucle de eventos, ya sea mediante Asyncify o de emscripten_set_main_loop, tiene beneficios si decides agregar algún tipo de animación o interactividad.

Cómo controlar las interacciones del usuario

Por ejemplo, con algunos cambios en el último ejemplo, puedes hacer que el rectángulo se mueva en respuesta a los eventos del teclado:

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

Cómo dibujar otras formas con SDL2_gfx

SDL2 abstrae las diferencias entre plataformas y varios tipos de dispositivos multimedia en una sola API, pero sigue siendo una biblioteca de bajo nivel. En especial para gráficos, si bien proporciona APIs para dibujar puntos, líneas y rectángulos, la implementación de formas y transformaciones más complejas queda a cargo del usuario.

SDL2_gfx es una biblioteca separada que llena ese vacío. Por ejemplo, se puede usar para reemplazar un rectángulo del ejemplo anterior por un círculo:

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

Ahora, también se debe vincular la biblioteca SDL2_gfx a la aplicación. Se hace de manera similar al 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

Y estos son los resultados que se ejecutan en Linux:

Una ventana cuadrada de Linux con fondo negro y un círculo verde.

Y en la Web:

Página HTML con guiones gráficos que muestra un círculo verde sobre un lienzo cuadrado negro.

Para conocer más primitivas gráficas, consulta los documentos generados automáticamente.