Рисование на холсте в Emscripten

Узнайте, как визуализировать 2D-графику в Интернете из WebAssembly с помощью Emscripten.

В разных операционных системах используются разные API для рисования графики. Различия становятся еще более запутанными при написании кроссплатформенного кода или переносе графики из одной системы в другую, в том числе при переносе собственного кода в WebAssembly.

В этом посте вы узнаете несколько методов рисования 2D-графики на элементе холста в Интернете из кода C или C++, скомпилированного с помощью Emscripten.

Холст через Embind

Если вы начинаете новый проект, а не пытаетесь портировать существующий, возможно, проще всего использовать HTML Canvas API через систему привязки Emscripten Embind . Embind позволяет вам напрямую работать с произвольными значениями JavaScript.

Чтобы понять, как использовать Embind, сначала взгляните на следующий пример из MDN , который находит элемент <canvas> и рисует на нем несколько фигур.

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

Вот как его можно транслитерировать на C++ с помощью 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);
}

При связывании этого кода обязательно передайте --bind , чтобы включить Embind:

emcc --bind example.cpp -o example.html

Затем вы можете использовать скомпилированные ресурсы на статическом сервере и загрузить пример в браузер:

HTML-страница, сгенерированная Emscripten, с зеленым прямоугольником на черном холсте.

Выбор элемента холста

При использовании HTML-оболочки, сгенерированной Emscripten, с предыдущей командой оболочки, холст включается и настраивается автоматически. Это упрощает создание простых демонстраций и примеров, но в более крупных приложениях вам может потребоваться включить сгенерированный Emscripten JavaScript и WebAssembly в HTML-страницу вашего собственного дизайна.

Сгенерированный код JavaScript ожидает найти элемент холста, хранящийся в свойстве Module.canvas . Как и другие свойства модуля , его можно установить во время инициализации.

Если вы используете режим ES6 (устанавливая для вывода путь с расширением .mjs или используя параметр -s EXPORT_ES6 ), вы можете передать холст следующим образом:

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

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

Если вы используете обычный вывод сценария, вам необходимо объявить объект Module перед загрузкой файла JavaScript, сгенерированного Emscripten:

<script>
var Module = {
  canvas: document.getElementById('my-canvas')
};
</script>
<script src="emscripten-generated.js"></script>

OpenGL и SDL2

OpenGL — популярный кроссплатформенный API для компьютерной графики. При использовании в Emscripten он позаботится о преобразовании поддерживаемого подмножества операций OpenGL в WebGL . Если ваше приложение использует функции, поддерживаемые в OpenGL ES 2.0 или 3.0, но не в WebGL, Emscripten также может позаботиться об их эмуляции, но вам необходимо дать свое согласие в соответствующих настройках .

Вы можете использовать OpenGL напрямую или через библиотеки 2D и 3D графики более высокого уровня. Несколько из них были портированы в Интернет с помощью Emscripten. В этом посте я концентрируюсь на 2D-графике, и для этого SDL2 в настоящее время является предпочтительной библиотекой, поскольку она была хорошо протестирована и официально поддерживает серверную часть Emscripten.

Рисование прямоугольника

В разделе «О SDL» на официальном сайте написано:

Simple DirectMedia Layer — это кроссплатформенная библиотека разработки, предназначенная для обеспечения низкоуровневого доступа к аудио, клавиатуре, мыши, джойстику и графическому оборудованию через OpenGL и Direct3D.

Все эти функции — управление звуком, клавиатурой, мышью и графикой — были портированы и работают с Emscripten в Интернете, поэтому вы можете без особых хлопот переносить целые игры, созданные с помощью SDL2. Если вы портируете существующий проект, ознакомьтесь с разделом «Интеграция с системой сборки» документации Emscripten.

Для простоты в этом посте я сосредоточусь на случае с одним файлом и переведу предыдущий пример прямоугольника на 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
}

При связывании с Emscripten вам необходимо использовать -s USE_SDL=2 . Это заставит Emscripten получить библиотеку SDL2, уже предварительно скомпилированную в WebAssembly, и связать ее с вашим основным приложением.

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

Когда пример загрузится в браузер, вы увидите знакомый зеленый прямоугольник:

HTML-страница, сгенерированная Emscripten, с изображением зеленого прямоугольника на черном квадратном холсте.

Однако у этого кода есть несколько проблем. Во-первых, ему не хватает надлежащей очистки выделенных ресурсов. Во-вторых, в Интернете страницы не закрываются автоматически после завершения выполнения приложения, поэтому изображение на холсте сохраняется. Однако, когда тот же код перекомпилируется с помощью

clang example.cpp -o example -lSDL2

и при выполнении созданное окно будет мигать лишь ненадолго и сразу закрываться при выходе, поэтому у пользователя не будет возможности увидеть изображение.

Интеграция цикла событий

Более полным и идиоматическим примером будет необходимость ожидания в цикле событий, пока пользователь не решит выйти из приложения:

#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();
}

После того, как изображение было нарисовано в окне, приложение теперь ожидает в цикле, где оно может обрабатывать клавиатуру, мышь и другие пользовательские события. Когда пользователь закрывает окно, он запускает событие SDL_QUIT , которое будет перехвачено для выхода из цикла. После выхода из цикла приложение выполнит очистку, а затем завершится само.

Теперь компиляция этого примера в Linux работает как положено и показывает окно размером 300 на 300 с зеленым прямоугольником:

Квадратное окно Linux с черным фоном и зеленым прямоугольником.

Однако пример больше не работает в Интернете. Страница, сгенерированная Emscripten, сразу зависает во время загрузки и никогда не показывает визуализированное изображение:

HTML-страница, сгенерированная Emscripten, с сообщением об ошибке «Страница не отвечает», предлагающим либо дождаться, пока страница станет ответственной, либо выйти со страницы.

Что случилось? Процитирую ответ из статьи «Использование асинхронных веб-API из WebAssembly» :

Вкратце, браузер запускает все фрагменты кода в виде бесконечного цикла, беря их из очереди один за другим. Когда срабатывает какое-то событие, браузер ставит соответствующий обработчик в очередь, а на следующей итерации цикла он извлекается из очереди и выполняется. Этот механизм позволяет моделировать параллелизм и выполнять множество параллельных операций, используя только один поток.

Об этом механизме важно помнить, что во время выполнения вашего пользовательского кода JavaScript (или WebAssembly) цикл событий блокируется […]

В предыдущем примере выполняется бесконечный цикл событий, а сам код выполняется внутри другого бесконечного цикла событий, неявно предоставленного браузером. Внутренний цикл никогда не передает управление внешнему, поэтому браузер не имеет возможности обрабатывать внешние события или рисовать что-либо на странице.

Есть два способа решить эту проблему.

Разблокировка цикла событий с помощью Asyncify

Во-первых, как описано в связанной статье , вы можете использовать Asyncify . Это функция Emscripten, которая позволяет «приостановить» программу C или C++, вернуть управление циклу событий и разбудить программу после завершения какой-либо асинхронной операции.

Такой асинхронной операцией может быть даже «сон в течение минимально возможного времени», выраженный через API emscripten_sleep(0) . Встраивая его в середину цикла, я могу гарантировать, что элемент управления будет возвращаться в цикл событий браузера на каждой итерации, а страница останется отзывчивой и сможет обрабатывать любые события:

#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();
}

Теперь этот код необходимо скомпилировать с включенным Asyncify:

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

И приложение снова работает в Интернете, как и ожидалось:

HTML-страница, сгенерированная Emscripten, с изображением зеленого прямоугольника на черном квадратном холсте.

Однако Asyncify может иметь нетривиальные издержки размера кода. Если он используется только для цикла событий верхнего уровня в приложении, лучшим вариантом может быть использование функции emscripten_set_main_loop .

Разблокировка цикла событий с помощью API «основного цикла»

emscripten_set_main_loop не требует каких-либо преобразований компилятора для разматывания и перемотки стека вызовов, что позволяет избежать накладных расходов на размер кода. Однако взамен это требует гораздо большего количества ручных модификаций кода.

Во-первых, тело цикла событий необходимо выделить в отдельную функцию. Затем необходимо вызвать emscripten_set_main_loop с этой функцией в качестве обратного вызова в первом аргументе, FPS во втором аргументе ( 0 для собственного интервала обновления) и логическое значение, указывающее, следует ли моделировать бесконечный цикл ( true ) в третьем:

emscripten_set_main_loop(callback, 0, true);

Вновь созданный обратный вызов не будет иметь никакого доступа к переменным стека в main функции, поэтому такие переменные, как window и renderer , необходимо либо извлечь в структуру, выделенную в куче, и ее указатель передать через вариант API emscripten_set_main_loop_arg , либо извлечь в глобальные static переменные (для простоты я выбрал последнее). За результатом немного сложнее следить, но он рисует тот же прямоугольник, что и в последнем примере:

#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();
}

Поскольку все изменения потока управления выполняются вручную и отражаются в исходном коде, его можно снова скомпилировать без функции Asyncify:

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

Этот пример может показаться бесполезным, поскольку он ничем не отличается от первой версии, где прямоугольник был успешно нарисован на холсте, несмотря на то, что код намного проще, а событие SDL_QUIT — единственное, которое обрабатывается в функции handle_events — игнорируется в в любом случае сеть.

Однако правильная интеграция цикла событий — либо через Asyncify, либо через emscripten_set_main_loop — окупится, если вы решите добавить какой-либо вид анимации или интерактивности.

Обработка взаимодействия с пользователем

Например, внеся несколько изменений в последний пример, вы можете заставить прямоугольник перемещаться в ответ на события клавиатуры:

#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();
}

Рисование других фигур с помощью SDL2_gfx

SDL2 абстрагирует межплатформенные различия и различные типы мультимедийных устройств в одном API, но это по-прежнему довольно низкоуровневая библиотека. В частности, для графики, хотя он предоставляет API для рисования точек, линий и прямоугольников, реализация любых более сложных форм и преобразований остается на усмотрение пользователя.

SDL2_gfx — отдельная библиотека, которая заполняет этот пробел. Например, его можно использовать для замены прямоугольника в приведенном выше примере кругом:

#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();
}

Теперь к приложению также необходимо подключить библиотеку SDL2_gfx. Это делается аналогично 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

А вот результаты работы в Linux:

Квадратное окно Linux с черным фоном и зеленым кругом.

И в сети:

HTML-страница, сгенерированная Emscripten, с зеленым кружком на черном квадратном холсте.

Дополнительные графические примитивы можно найти в автоматически созданной документации .