Desenho em tela em Emscripten

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:

Página HTML gerada pelo Emscripten mostrando um retângulo verde em 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ê. 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. Por isso, a biblioteca SDL2 é a preferida porque já 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:

Página HTML gerada pelo Emscripten mostrando um retângulo verde em uma tela quadrada preta.

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:

Uma janela quadrada do Linux com plano de 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 por &quot;A página não responde&quot; caixa de diálogo de erro sugerindo que você aguarde a página se tornar responsável ou saia dela

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 Asyncify. É 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:

Página HTML gerada pelo Emscripten mostrando um retângulo verde em uma tela quadrada preta.

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:

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

E na Web:

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

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