Узнайте, как визуализировать 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, с предыдущей командой оболочки, холст включается и настраивается автоматически. Это упрощает создание простых демонстраций и примеров, но в более крупных приложениях вам может потребоваться включить сгенерированные 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
Когда пример загрузится в браузер, вы увидите знакомый зеленый прямоугольник:
Однако у этого кода есть несколько проблем. Во-первых, ему не хватает надлежащей очистки выделенных ресурсов. Во-вторых, в Интернете страницы не закрываются автоматически после завершения выполнения приложения, поэтому изображение на холсте сохраняется. Однако, когда тот же код перекомпилируется с помощью
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 с зеленым прямоугольником:
Однако пример больше не работает в Интернете. Страница, сгенерированная 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
И приложение снова работает в Интернете, как и ожидалось:
Однако 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:
И в сети:
Дополнительные графические примитивы можно найти в автоматически созданной документации .