Cómo dibujar a lienzo en Emscripten

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

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

En esta publicación, aprenderás algunos 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.

Canvas a través de 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 Embind de Emscripten. Embind te permite operar directamente en valores arbitrarios de JavaScript.

Para comprender cómo usar Embind, primero observa el siguiente ejemplo de MDN que encuentra un elemento <canvas> y dibuja algunas formas en él.

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

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

A continuación, se muestra 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 entregar los recursos compilados con un servidor estático y cargar el ejemplo en un navegador:

Página HTML generada por Emscripten que muestra un rectángulo verde sobre un lienzo negro.

Cómo elegir el elemento de lienzo

Cuando usas la shell HTML generada por Emscripten con el comando de shell anterior, se incluye y configura el lienzo por ti. Facilita la compilación de demos y ejemplos simples, pero en aplicaciones más grandes, te recomendamos que incluyas el código JavaScript y WebAssembly generado por Emscripten en una página HTML de tu propio diseño.

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

Si usas el modo ES6 (configurando el resultado en una ruta con una extensión .mjs o usando 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 un resultado de secuencia de comandos normal, 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 por computadora. Cuando se usa en Emscripten, se encargará 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 con WebGL, Emscripten también puede encargarse de emularlas, pero debes habilitarlas a través de la configuración correspondiente.

Puedes usar OpenGL directamente o a través de bibliotecas de gráficos 2D y 3D de nivel superior. Algunos de ellos se portaron a la Web con Emscripten. En esta publicación, me enfoco en los gráficos en 2D y, por eso, SDL2 es actualmente la biblioteca preferida porque se probó bien y admite el backend de Emscripten oficialmente en upstream.

Cómo dibujar un rectángulo

En la sección "Acerca de SDL" del sitio web oficial, se indica lo siguiente:

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

Todas esas funciones, como el control de audio, teclado, mouse y gráficos, se portaron y también funcionan con Emscripten en la Web, por lo que puedes portar juegos completos compilados con SDL2 sin muchos problemas. Si vas a portar un proyecto existente, consulta la sección "Integración con un sistema de compilación" de la documentación de Emscripten.

Para simplificar, en esta publicación, me enfocaré en un caso de un solo archivo y traduciré el ejemplo de rectángulo anterior 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 realices la vinculación con Emscripten, debes usar -s USE_SDL=2. Esto le indicará a Emscripten que recupere la biblioteca SDL2, ya precompilada en WebAssembly, y la vincule con tu aplicación principal.

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

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

Página HTML generada por Emscripten que muestra un rectángulo verde en un lienzo cuadrado negro.

Sin embargo, este código tiene algunos problemas. En primer lugar, no limpia correctamente los recursos asignados. En segundo lugar, en la Web, las páginas no se cierran automáticamente cuando una aplicación termina de ejecutarse, 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 se ejecuta, la ventana creada solo parpadeará brevemente y se cerrará de inmediato cuando se salga, por lo que el usuario no tendrá la oportunidad de ver la imagen.

Cómo integrar un bucle de eventos

Un ejemplo más completo y idiomático sería esperar en un bucle de eventos hasta que el usuario decida 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 dibuja en una ventana, la aplicación espera en un bucle, en el que puede procesar el teclado, el mouse y otros eventos del usuario. Cuando el usuario cierre la ventana, activará un evento SDL_QUIT, que se interceptará para salir del bucle. Después de salir del bucle, la aplicación realizará la limpieza y, luego, se cerrará.

Ahora, compilar 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 congela de inmediato durante la carga y nunca muestra la imagen renderizada:

Página HTML generada por Emscripten superpuesta con un diálogo de error &quot;La página no responde&quot; que sugiere esperar a que la página responda o salir de ella

¿Qué pasó? Citaré la respuesta del artículo "Cómo usar APIs web asíncronas desde WebAssembly":

En resumen, el navegador ejecuta todos los fragmentos de código en una especie de bucle infinito, tomándolos de la cola uno por uno. Cuando se activa un evento, el navegador pone en cola el controlador correspondiente y, en la siguiente iteración del bucle, se lo quita de la cola y se ejecuta. Este mecanismo permite simular la simultaneidad y ejecutar muchas operaciones en paralelo con un solo subproceso.

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

En el ejemplo anterior, se ejecuta un bucle de eventos infinito, mientras que el código se ejecuta dentro de otro bucle de eventos infinito, que el navegador proporciona de forma implícita. El bucle interno nunca cede el control al externo, 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" el programa C o C++, devolver el control al bucle de eventos y activar el programa cuando finaliza alguna operación asíncrona.

Esta operación asíncrona puede ser incluso "suspender durante el tiempo mínimo posible", expresada a través de la API de emscripten_sleep(0). Si lo incorporo en medio del bucle, puedo asegurarme de que el control se devuelva al bucle de eventos del navegador en cada iteración, y 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, este código debe compilarse 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 generada por Emscripten que muestra un rectángulo verde en un lienzo cuadrado negro.

Sin embargo, Asyncify puede tener una sobrecarga de tamaño de código no trivial. 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.

Cómo desbloquear el bucle de eventos con las APIs de "bucle principal"

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

Primero, el cuerpo del bucle de eventos debe extraerse en una función independiente. 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 argumento (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 pila en la función main, por lo que las variables como window y renderer deben extraerse en una struct asignada al montón y su puntero pasarse a través de la variante emscripten_set_main_loop_arg de la API, o extraerse en variables static globales (opté por la última opción por motivos de 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 del flujo de control son manuales y se reflejan en el código fuente, se puede 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 el lienzo correctamente a pesar de que el código era mucho más simple, y el evento SDL_QUIT, el único que se controla en la función handle_events, se ignora de todos modos en la Web.

Sin embargo, la integración adecuada del bucle de eventos, ya sea a través de Asyncify o de emscripten_set_main_loop, vale la pena 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 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 multiplataforma y varios tipos de dispositivos multimedia en una sola API, pero sigue siendo una biblioteca de bajo nivel. En particular, para los 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 independiente que cubre esa brecha. Por ejemplo, se puede usar para reemplazar un rectángulo en el 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, la biblioteca SDL2_gfx también debe vincularse a la aplicación. Se hace de manera similar a 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

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 generada por Emscripten que muestra un círculo verde en un lienzo cuadrado negro.

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