Aprenda a renderizar gráficos 2D na Web pelo WebAssembly com o Emscripten.
Diferentes sistemas operacionais têm diferentes APIs para desenhar gráficos. As diferenças se tornam ainda mais confusas ao escrever um código entre plataformas ou ao fazer a portabilidade de gráficos de um sistema para outro, inclusive ao fazer a portabilidade de código nativo para o WebAssembly.
Nesta postagem, você vai aprender alguns métodos para desenhar gráficos 2D no elemento canvas na Web a partir de código C ou C++ compilado com o Emscripten.
Tela via Embind
Se você estiver iniciando um novo projeto em vez de tentar transferir um existente, talvez seja mais fácil usar a API Canvas HTML pelo sistema de vinculação Embind da Emscripten. O Embind permite que você opere diretamente em valores arbitrários de JavaScript.
Para entender como usar o Embind, primeiro dê uma olhada no seguinte exemplo do MDN que encontra uma <tela> e desenha formas nele
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);
Veja como ele pode ser transliterado para C++ com o 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);
}
Ao vincular esse código, transmita --bind
para ativar o Embind:
emcc --bind example.cpp -o example.html
Em seguida, é possível veicular os recursos compilados com um servidor estático e carregar o exemplo em um navegador:
Escolher o elemento "canvas"
Ao usar o shell HTML gerado pelo Emscripten com o comando shell anterior, a tela é incluída e configurada para você. Isso facilita a criação de demonstrações e exemplos simples, mas em aplicativos maiores é uma boa ideia incluir JavaScript e WebAssembly gerados por Emscripten em uma página HTML criada por você.
O código JavaScript gerado espera encontrar o elemento de tela armazenado na propriedade Module.canvas
. Assim como outras propriedades do módulo, ela pode ser definida durante a inicialização.
Se você estiver usando o modo ES6 (definindo a saída para um caminho com uma extensão .mjs
ou usando a configuração -s EXPORT_ES6
), poderá transmitir a tela da seguinte maneira:
import initModule from './emscripten-generated.mjs';
const Module = await initModule({
canvas: document.getElementById('my-canvas')
});
Se você estiver usando a saída de script regular, precisará declarar o objeto Module
antes de carregar o arquivo JavaScript gerado pelo Emscripten:
<script>
var Module = {
canvas: document.getElementById('my-canvas')
};
</script>
<script src="emscripten-generated.js"></script>
OpenGL e SDL2
OpenGL é uma API multiplataforma conhecida para computação gráfica. Quando usado no Emscripten, ele converte o subconjunto compatível de operações do OpenGL para WebGL. Se seu aplicativo depende de recursos com suporte no OpenGL ES 2.0 ou 3.0, mas não no WebGL, o Emscripten também pode emular esses recursos, mas você precisa ativá-los nas configurações correspondentes.
Você pode usar o OpenGL diretamente ou por meio de bibliotecas de gráficos 2D e 3D de nível superior. Alguns deles foram transferidos para a web com a Emscripten. Nesta postagem, vamos nos concentrar nos gráficos 2D. Para isso, a biblioteca SDL2 é a preferida porque foi bem testada e é compatível com o back-end Emscripten oficialmente upstream.
Como desenhar um retângulo
"Sobre SDL" no site oficial diz:
Simple DirectMedia Layer é uma biblioteca de desenvolvimento multiplataforma projetada para fornecer acesso de baixo nível a áudio, teclado, mouse, joystick e hardware gráfico por meio de OpenGL e Direct3D.
Todos esses recursos (controle de áudio, teclado, mouse e gráficos) foram transferidos e também funcionam com o Emscripten na Web, para que você possa transferir jogos inteiros criados com SDL2 sem muita dificuldade. Se você estiver fazendo a portabilidade de um projeto, confira a seção "Integrating with a build system" (em inglês) na documentação da Emscripten.
Para simplificar, nesta postagem, vou me concentrar em um caso de arquivo único e traduzir o exemplo de retângulo anterior para 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
}
Ao vincular com o Emscripten, você precisa usar -s USE_SDL=2
. Isso dirá ao Emscripten para buscar a biblioteca SDL2, já pré-compilada para o WebAssembly, e vinculá-la ao seu aplicativo principal.
emcc example.cpp -o example.html -s USE_SDL=2
Quando o exemplo for carregado no navegador, você verá o familiar retângulo verde:
No entanto, esse código tem alguns problemas. Primeiro, falta a limpeza adequada de recursos alocados. Segundo, na Web, as páginas não são fechadas automaticamente quando um aplicativo termina a execução, então a imagem na tela é preservada. No entanto, quando o mesmo código é recompilado nativamente com
clang example.cpp -o example -lSDL2
e executada, a janela criada só piscará brevemente e fechará imediatamente ao sair, para que o usuário não tenha a chance de ver a imagem.
Como integrar um loop de eventos
Um exemplo mais completo e idiomático parece ter que esperar em um loop de eventos até que o usuário opte por sair do aplicativo:
#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();
}
Depois que a imagem é desenhada em uma janela, o aplicativo espera em um loop, onde pode processar eventos de teclado, mouse e outros eventos do usuário. Quando o usuário fechar a janela, ele acionará um evento SDL_QUIT
, que será interceptado para sair da repetição. Depois que o loop é encerrado, o aplicativo faz a limpeza e se encerra.
Agora, a compilação desse exemplo no Linux funciona conforme o esperado e mostra uma janela de 300 por 300 com um retângulo verde:
No entanto, o exemplo não funciona mais na Web. A página gerada pelo Emscripten congela imediatamente durante o carregamento e nunca mostra a imagem renderizada:
O que aconteceu? Vou citar a resposta do artigo "Como usar APIs assíncronas da Web do WebAssembly":
Na versão curta, o navegador executa todas as partes do código em uma espécie de loop infinito, retirando-as da fila uma a uma. Quando algum evento é acionado, o navegador coloca o manipulador correspondente na fila e, na próxima iteração de loop, ele é retirado da fila e executado. Esse mecanismo permite simular a simultaneidade e executar muitas operações paralelas usando apenas uma linha de execução.
O importante a ser lembrado sobre esse mecanismo é que, embora seu código JavaScript (ou WebAssembly) personalizado seja executado, o loop de eventos está bloqueado [...]
O exemplo anterior executa um loop de eventos infinito, enquanto o próprio código é executado dentro de outro loop de eventos infinito, fornecido implicitamente pelo navegador. O loop interno nunca renuncia ao controle para o externo. Portanto, o navegador não tem a chance de processar eventos externos ou desenhar coisas na página.
Há duas maneiras de corrigir esse problema.
Como desbloquear um loop de eventos com o Asyncify
Primeiro, conforme descrito no artigo vinculado, você pode usar o Async. É um recurso da Emscripten que permite "pausar" programa em C ou C++, devolva o controle ao loop de eventos e ative o programa quando alguma operação assíncrona for concluída.
Essa operação assíncrona pode até estar "suspensa pelo menor tempo possível", expresso pela API emscripten_sleep(0)
. Ao incorporá-lo no meio do loop, é possível garantir que o controle retorne ao loop de eventos do navegador em cada iteração, e que a página permaneça responsiva e possa lidar com qualquer 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();
}
Agora, este código precisa ser compilado com o Asyncify ativado:
emcc example.cpp -o example.html -s USE_SDL=2 -s ASYNCIFY
E o aplicativo vai voltar a funcionar na Web como esperado:
No entanto, o Asyncify pode ter uma sobrecarga de tamanho de código não trivial. Se ela for usada apenas para uma repetição de eventos de nível superior no aplicativo, uma opção melhor é usar a função emscripten_set_main_loop
.
Desbloqueio do loop de eventos com o "loop principal" APIs
O emscripten_set_main_loop
não requer transformações do compilador para liberar e retroceder a pilha de chamadas, evitando a sobrecarga de tamanho do código. No entanto, em troca, isso exige muito mais modificações manuais no código.
Primeiro, o corpo do loop de eventos precisa ser extraído para uma função separada. Em seguida, emscripten_set_main_loop
precisa ser chamado com essa função como callback no primeiro argumento, um QPS no segundo argumento (0
para o intervalo de atualização nativa) e um booleano indicando se um loop infinito será simulado (true
) no terceiro:
emscripten_set_main_loop(callback, 0, true);
O callback recém-criado não terá acesso às variáveis de pilha na função main
. Portanto, variáveis como window
e renderer
precisam ser extraídas para um struct alocado por heap e o ponteiro transmitido pela variante emscripten_set_main_loop_arg
da API ou extraído para variáveis static
globais (usei a última para simplificar). O resultado é um pouco mais difícil de acompanhar, mas desenha o mesmo retângulo do último exemplo:
#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();
}
Como todas as mudanças no fluxo de controle são manuais e refletidas no código-fonte, ele pode ser compilado novamente sem o recurso Asyncify:
emcc example.cpp -o example.html -s USE_SDL=2
Esse exemplo pode parecer inútil, porque não funciona de maneira diferente da primeira versão, em que o retângulo foi desenhado na tela, apesar do código ser muito mais simples, e o evento SDL_QUIT
, o único processado na função handle_events
, é ignorado na Web de qualquer maneira.
No entanto, a integração adequada do loop de eventos, seja pelo Async ou por emscripten_set_main_loop
, vale a pena se você decidir adicionar qualquer tipo de animação ou interatividade.
Como processar interações do usuário
Por exemplo, com algumas alterações no último exemplo, você pode fazer o retângulo se mover em resposta a eventos de 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();
}
Como desenhar outras formas com SDL2_gfx
A SDL2 abstrai as diferenças entre plataformas e vários tipos de dispositivos de mídia em uma única API, mas ainda é uma biblioteca de baixo nível. Particularmente para gráficos, embora ele ofereça APIs para desenhar pontos, linhas e retângulos, a implementação de formas e transformações mais complexas fica a critério do usuário.
SDL2_gfx é uma biblioteca separada que preenche essa lacuna. Por exemplo, ele pode ser usado para substituir um retângulo no exemplo acima por um 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();
}
Agora, a biblioteca SDL2_gfx também precisa ser vinculada ao aplicativo. Isso é semelhante ao 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
Estes são os resultados executados no Linux:
E na Web:
Para mais primitivos gráficos, confira os documentos gerados automaticamente.