Desenho em tela em Emscripten

Aprenda a renderizar gráficos 2D na Web usando o WebAssembly com o Emscripten.

Diferentes sistemas operacionais têm APIs diferentes para desenhar gráficos. As diferenças ficam ainda mais confusas ao escrever um código multiplataforma ou transferir gráficos de um sistema para outro, incluindo a transferência de código nativo para o WebAssembly.

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

Se você estiver iniciando um novo projeto em vez de tentar portar um existente, talvez seja mais fácil usar a API Canvas do HTML pelo sistema de vinculação Embind do Emscripten. O Embind permite operar diretamente em valores arbitrários do JavaScript.

Para entender como usar o Embind, confira o exemplo do MDN a seguir, 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 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 exibir 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.

Como escolher o elemento de tela

Ao usar o shell HTML gerado pelo Emscripten com o comando de 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, é recomendável incluir o JavaScript e o WebAssembly gerados pelo Emscripten em uma página HTML de seu próprio design.

O código JavaScript gerado espera encontrar o elemento da 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), transmita a tela assim:

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

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

Se você estiver usando a saída de script normal, declare 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

O OpenGL é uma API multiplataforma conhecida para gráficos de computador. Quando usado no Emscripten, ele cuida da conversão do subconjunto com suporte de operações do OpenGL para o WebGL. Se o aplicativo depender de recursos com suporte no OpenGL ES 2.0 ou 3.0, mas não na WebGL, o Emscripten também poderá cuidar da emulação deles, mas você precisará ativar essa opção nas configurações correspondentes.

Você pode usar o OpenGL diretamente ou por bibliotecas de gráficos 2D e 3D de nível mais alto. Alguns deles foram transferidos para a Web com o Emscripten. Neste post, vou me concentrar em gráficos 2D. Para isso, a SDL2 é a biblioteca preferida no momento, porque foi bem testada e oferece suporte ao back-end do Emscripten oficialmente upstream.

Desenhar um retângulo

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

A 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 do OpenGL e do Direct3D.

Todos esses recursos, como controle de áudio, teclado, mouse e gráficos, foram portados e funcionam com o Emscripten na Web. Assim, você pode portar jogos inteiros criados com o SDL2 sem muita dificuldade. Se você estiver fazendo a portabilidade de um projeto existente, confira a seção "Integração 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 o Emscripten, é necessário usar -s USE_SDL=2. Isso vai informar ao Emscripten para buscar a biblioteca SDL2, já pré-compilada para WebAssembly, e vincular ao seu aplicativo principal.

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

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

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, não há limpeza adequada dos recursos alocados. Em segundo lugar, 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 de forma nativa com

clang example.cpp -o example -lSDL2

e for executada, a janela criada 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 precisaria esperar em um loop de eventos até que o usuário decida 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 eventos de teclado, mouse e outros eventos do usuário. Quando o usuário fecha a janela, ele aciona um evento SDL_QUIT, que é interceptado para sair do loop. Depois que o loop é encerrado, o aplicativo faz a limpeza e é encerrado.

Agora, a compilação desse exemplo no Linux funciona como esperado e mostra uma janela de 300 x 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 pelo emscripten sobreposta a uma caixa de diálogo de erro &quot;Page Unresponsive&quot; que sugere aguardar a página se tornar responsável ou sair dela

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

Resumindo, o navegador executa todas as partes do código em um loop infinito, retirando-as da fila uma por uma. Quando algum evento é acionado, o navegador enfileira o gerenciador correspondente e, na próxima iteração do loop, ele é retirado da fila e executado. Esse mecanismo permite simular a simultaneidade e executar muitas operações paralelas usando apenas uma única linha de execução.

O importante sobre esse mecanismo é que, enquanto o código JavaScript (ou WebAssembly) personalizado é executado, o loop de eventos fica bloqueado […]

O exemplo anterior executa um loop de eventos infinito, enquanto o código é executado em outro loop de eventos infinito, fornecido implicitamente pelo navegador. O loop interno nunca renuncia ao controle para o externo, de modo que 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 o loop de eventos com o Asyncify

Primeiro, conforme descrito no artigo vinculado, você pode usar o Asyncify. É um recurso do Emscripten que permite "pausar" o programa 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 até "dormir pelo tempo mínimo possível", expresso 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 continue responsiva e possa processar todos os eventos:

#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, esse código precisa ser compilado com o Asyncify ativado:

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

E o aplicativo funciona como esperado na Web novamente:

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 ele for usado apenas para um loop de eventos de nível superior no aplicativo, uma opção melhor pode ser usar a função emscripten_set_main_loop.

Como desbloquear o loop de eventos com APIs "main loop"

O emscripten_set_main_loop não exige nenhuma transformação do compilador para desfazer e retroceder a pilha de chamadas. Dessa forma, o overhead do tamanho do código é evitado. 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 QPS no segundo argumento (0 para o intervalo de atualização nativa) 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 da pilha na função main. Portanto, variáveis como window e renderer precisam ser extraídas para uma estrutura alocada em heap e seu ponteiro transmitido pela variante emscripten_set_main_loop_arg da API ou extraído para variáveis static globais. Escolhi a última opção para simplificar. O resultado é um pouco mais difícil de seguir, mas ele 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 sem o recurso Asyncify novamente:

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

Esse exemplo pode parecer inútil, porque funciona de forma semelhante à 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 do loop de eventos, seja pelo Asyncify ou pelo 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 mudanças no último exemplo, é possível 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 o 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 baixo nível. Em particular, para gráficos, embora forneça APIs para desenhar pontos, linhas e retângulos, 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, 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 é 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 aqui estão os resultados executados no Linux:

Uma janela quadrada do Linux com 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 primitivas gráficas, confira as documentações geradas automaticamente.