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 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.
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 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:
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 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 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 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 de 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 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. 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
La sección "Acerca de SDL" del sitio web oficial dice lo siguiente:
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 la documentación 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 realices la vinculación 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:
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 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 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. Cuando se cierra el bucle, la aplicación realiza la limpieza y, luego, sale de sí misma.
Ahora, compilar este ejemplo en Linux funciona como se espera y muestra una ventana de 300 por 300 con 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:
¿Qué pasó? Citaré la respuesta del artículo "Cómo usar APIs web asíncronas desde WebAssembly":
En resumen, el navegador ejecuta todas las secciones de código en una especie de bucle infinito, tomándolas de la cola una por una. 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 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” 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:
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 retroceder la pila de llamadas y, de esa manera, evita la sobrecarga del 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 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 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 para simplificar). 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 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 a través de 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 multiplataforma 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 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, también se debe vincular la biblioteca SDL2_gfx 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
Y estos son los resultados que se ejecutan en Linux:
Y en la Web:
Para conocer más primitivas gráficas, consulta los documentos generados automáticamente.