Desenho em tela em Emscripten

Aprenda a 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 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.

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.

Tela por meio do 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 operar diretamente em valores arbitrários do JavaScript.

Para entender como usar o Embind, dê uma olhada no seguinte 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 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 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 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

O OpenGL é uma API multiplataforma conhecida para gráficos de computador. Quando usado no Emscripten, ele converte o subconjunto compatível de operações do OpenGL para 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. 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.

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 áudio, teclado, mouse, joystick e hardware gráfico por meio de OpenGL e 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, consulte 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, você precisa 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. Segundo, na Web, as páginas não são fechadas automaticamente quando um aplicativo termina sua 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 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 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 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 assíncronas da Web 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 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 única 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 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, é 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, 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 do "loop principal"

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 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 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 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 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 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. 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 é 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 a documentação gerada automaticamente.