Desenho em tela em Emscripten

Saiba como renderizar gráficos 2D na Web usando o 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 multiplataforma ou ao transferir gráficos de um sistema para outro, inclusive ao transferir código nativo para o WebAssembly.

Nesta postagem, você vai aprender alguns métodos para desenhar gráficos 2D no elemento canvas na Web usando o código C ou C++ compilado com Emscripten.

Canvas via Embind

Se você está iniciando um novo projeto em vez de tentar transferir um já existente, talvez seja mais fácil usar a API Canvas HTML por meio do sistema de vinculação Embind (em inglês) do Emscripten. O Embind permite que você opere diretamente em valores arbitrários de JavaScript.

Para entender como usar o Embind, primeiro confira este exemplo do MDN (em inglês) que encontra um elemento <canvas> e desenha algumas 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 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, você pode disponibilizar os recursos compilados com um servidor estático e carregar o exemplo em um navegador:

Página HTML gerada em script que mostra um retângulo verde sobre uma tela preta.

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ê. Ela facilita a criação de demonstrações e exemplos simples, mas em aplicativos maiores é desejável incluir o JavaScript gerado por Emscripten e o WebAssembly em uma página HTML de seu próprio design.

O código JavaScript gerado espera encontrar o elemento de tela armazenado na propriedade Module.canvas. Assim como outras propriedades de módulo, ele pode ser definido durante a inicialização.

Se 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), transmita a tela desta forma:

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

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

Se você estiver usando uma saída de script normal, será necessário 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 conhecida API multiplataforma para computação gráfica. Quando usado no Emscripten, ele cuida da conversão do subconjunto compatível de operações OpenGL para o WebGL. Se o aplicativo depende de recursos compatíveis com o OpenGL ES 2.0 ou 3.0, mas não com WebGL, o Emscripten também pode fazer a emulação desses recursos. No entanto, você precisa ativar essa opção 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 Emscripten. Nesta postagem, meu foco são gráficos 2D. Para isso, SDL2 é a biblioteca preferida atualmente, porque foi bem testada e oferece suporte oficial ao back-end Emscripten.

Desenhar um retângulo

A seção "Sobre o SDL" no site oficial diz:

Simple DirectMedia Layer é uma biblioteca de desenvolvimento multiplataforma projetada para fornecer acesso de baixo nível a hardware de áudio, teclado, mouse, joystick e gráfico por OpenGL e Direct3D.

Todos esses recursos, como controle de áudio, teclado, mouse e gráficos, foram transferidos e também funcionam com o Emscripten na Web. Assim, é possível transferir jogos inteiros criados com SDL2 sem complicações. Se você estiver transferindo um projeto que já existe, confira a seção Como integrar com um sistema de build dos documentos do 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 Emscripten, você precisa usar -s USE_SDL=2. Isso fará com que o Emscripten busque a biblioteca SDL2, já pré-compilada para o WebAssembly, e a vincule ao aplicativo principal.

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

Quando o exemplo for carregado no navegador, você verá o conhecido retângulo verde:

Página HTML gerada em script que mostra um retângulo verde sobre uma tela quadrada preta.

No entanto, este código tem alguns problemas. Primeiro, falta a limpeza adequada dos recursos alocados. Segundo, na Web, as páginas não são fechadas automaticamente quando um aplicativo termina a execução, portanto 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ó vai piscar brevemente e fechar imediatamente após a saída, 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 pareceria 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 aguarda em um loop, onde pode processar 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 depois se fecha.

Agora, a compilação deste exemplo no Linux funciona como esperado e mostra uma janela de 300 por 300 com um retângulo verde:

Uma janela quadrada do Linux com fundo preto e 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:

Página HTML gerada em script sobreposta com uma caixa de diálogo de erro &quot;Página sem resposta&quot; sugerindo aguardar até que a página se torne responsável ou sair da página

O que aconteceu? Vou citar a resposta do artigo "Como usar APIs da Web assíncronas do WebAssembly":

A versão resumida é que o navegador executa todas as partes do código em uma espécie de loop infinito, tirando-as da fila uma por 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 mais importante a se lembrar sobre esse mecanismo é que, enquanto seu código JavaScript (ou WebAssembly) personalizado é executado, o loop de eventos é 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. Como o loop interno nunca libera o controle para o externo, o navegador não tem a chance de processar eventos externos ou desenhar itens na página.

Há duas maneiras de corrigir esse problema.

Desbloquear a repetição de eventos com o Asyncify

Primeiro, conforme descrito no artigo vinculado, você pode usar o Asyncify. É um recurso Emscripten que permite "pausar" o programa em C ou C++, devolver o controle ao loop de eventos e ativar o programa quando alguma operação assíncrona for concluída.

Essa operação assíncrona pode ser até mesmo "em suspensão pelo tempo mínimo possível", expressa pela API emscripten_sleep(0). Ao incorporá-lo no meio do loop, posso garantir que o controle seja retornado ao loop de eventos do navegador em cada iteração e que a página permanecerá responsiva e poderá processar 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();
}

Este código agora precisa ser compilado com a opção "Asyncify" ativada:

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

E o aplicativo voltar a funcionar como esperado na Web:

Página HTML gerada em script que mostra um retângulo verde sobre uma tela quadrada preta.

No entanto, o Asyncify pode ter uma sobrecarga do tamanho do código não trivial. Se ele for usado apenas para um loop de eventos de nível superior no aplicativo, a melhor opção será usar a função emscripten_set_main_loop.

Desbloquear loop de eventos com APIs de "loop principal"

emscripten_set_main_loop não requer transformações do compilador para liberar e retroceder a pilha de chamadas, evitando a sobrecarga do 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 um callback no primeiro argumento, um FPS no segundo argumento (0 para o intervalo de atualização nativo) e um booleano indicando se é necessário simular um loop infinito (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 uma estrutura alocada por heap e o ponteiro dela transmitido pela variante emscripten_set_main_loop_arg da API ou extraídos para variáveis static globais. Usei a última opção 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 aparecem 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.

No entanto, a integração adequada de loop de eventos, seja via Asyncify ou 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

O 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 nível bem baixo. Embora ela ofereça APIs para desenhar pontos, linhas e retângulos principalmente para gráficos, a implementação de formas e transformações mais complexas é deixada para o usuário.

SDL2_gfx é uma biblioteca separada que preenche essa lacuna. Por exemplo, ela pode ser usada 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 é feito de forma 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

E estes são os resultados em execução no Linux:

Uma janela quadrada do Linux com fundo preto e um círculo verde.

E na Web:

Página HTML gerada em script mostrando um círculo verde sobre uma tela quadrada preta.

Para acessar mais primitivos gráficos, confira os documentos gerados automaticamente.