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 distintas APIs para dibujar gráficos. Las diferencias se vuelven aún más confusas cuando se escribe un código multiplataforma o se porta 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 en 2D en el elemento lienzo de la Web a partir de código C o C++ compilado con Emscripten.

Lienzo a través de Embind

Si estás iniciando un proyecto nuevo en lugar de intentar portar uno existente, podría ser más fácil usar la API de Canvas HTML mediante el 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, 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 entregar los elementos 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

Elige el elemento de lienzo

Cuando usas la shell HTML generada por Emscripten con el comando shell anterior, se incluye el lienzo y se configura por ti. Facilita la compilación de demostraciones y ejemplos simples, pero en aplicaciones más grandes sería conveniente 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 usando el parámetro -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 use en Emscripten, se encargará de convertir el subconjunto compatible de operaciones de OpenGL a WebGL. Si tu aplicación se basa en funciones compatibles con OpenGL ES 2.0 o 3.0, pero no en WebGL, Emscripten también se encarga de emularlas, pero debes habilitarlas mediante 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 trasladaron a la Web con Emscripten. En esta publicación, me enfoco en los gráficos 2D para eso, por el momento, SDL2 es la biblioteca preferida porque se probó y admite el backend de Emscripten oficialmente.

Cómo dibujar un rectángulo

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

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

Todas esas funciones (control de audio, teclado, mouse y gráficos) se trasladaron y también funcionan con Emscripten en la Web para que puedas transferir juegos completos creados con SDL2 sin complicaciones. Si quieres transferir un proyecto existente, consulta la sección "Cómo integrar 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 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 vincules con Emscripten, deberás usar -s USE_SDL=2. Esto le indicará a Emscripten que recupere la biblioteca SDL2, ya compilada con anterioridad, y la vincule con tu aplicación principal.

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

Cuando el ejemplo esté cargado en el navegador, verás el conocido rectángulo verde:

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

Sin embargo, este código tiene algunos problemas. Primero, 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 parpadeará brevemente y se cerrará inmediatamente al salir, por lo que el usuario no tendrá la oportunidad de ver la imagen.

Cómo integrar un bucle de eventos

Un ejemplo más idiomático y completo tendría que 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 dibujó en una ventana, la aplicación espera en bucle, donde puede procesar eventos del teclado, del mouse y otros eventos del usuario. Cuando el usuario cierre la ventana, activará un evento SDL_QUIT, que se interceptará para salir del bucle. Una vez que se cierra el bucle, la aplicación realiza la limpieza y, luego, sale sola.

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 un fondo negro y un rectángulo verde.

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

Página HTML generada por Emscripten con un diálogo de error que indica que la página no responde y que sugiere esperar a que la página sea responsable o salir de ella

¿Qué pasó? Citaré la respuesta del artículo "Using asíncrono web APIs from 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 del bucle, se lo 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, si bien se ejecuta tu código JavaScript (o WebAssembly) personalizado, el bucle de eventos se bloquea [...]

En el ejemplo anterior, se ejecuta un bucle de eventos infinito, mientras que el código en sí se ejecuta dentro de otro bucle de eventos infinito proporcionado implícitamente por el navegador. 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.

Desbloquea 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 de C o C++, devolver el control al bucle de eventos y activar el programa cuando finaliza una operación asíncrona.

Esta operación asíncrona incluso puede ser "suspendida por el tiempo mínimo posible", expresada a través de la API de emscripten_sleep(0). Si lo incorporas en el medio del bucle, me aseguro de que el control se devuelva al 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 se debe 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 generada por Emscripten 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.

Desbloqueo de bucle de eventos con las APIs de “bucle principal”

emscripten_set_main_loop no requiere ninguna transformación del compilador para desenrollar y retroceder la pila de llamadas; de esta manera, se evita la sobrecarga de tamaño del código. Sin embargo, a cambio, requiere muchas más modificaciones manuales al 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 indica 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 una struct asignada del montón y su puntero se pasa a través de la variante emscripten_set_main_loop_arg de la API o se deben extraer en variables static globales (usé la última para mayor 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 volver a compilar sin la función Asyncify:

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

Este ejemplo puede parecer inútil, ya que no funciona diferente de la primera versión, en la que el rectángulo se dibujó correctamente en el lienzo a pesar de que el código es 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.

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 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 multiplataforma y varios tipos de dispositivos multimedia en una sola API, pero sigue siendo una biblioteca bastante 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 es la tarea del usuario.

SDL2_gfx es una biblioteca independiente que llena ese vacío. 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 estar vinculada a la aplicación. Esto 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 un fondo negro y un círculo verde.

Y en la Web:

Página HTML generada por Emscripten que muestra un círculo verde sobre un lienzo cuadrado negro

Para ver más primitivas de gráficos, consulta los documentos generados automáticamente.