Emscripten を使用して WebAssembly からウェブ上で 2D グラフィックをレンダリングする方法を学びます。
オペレーティング システムによって、グラフィックを描画するための API は異なります。クロス プラットフォーム コードを記述する場合や、ネイティブ コードを WebAssembly に移植する場合など、あるシステムから別のシステムにグラフィックを移植する場合、これらの違いはさらに複雑になります。
この投稿では、Emscripten でコンパイルされた C / C++ コードから、ウェブ上のキャンバス要素に 2D グラフィックを描画するための方法をいくつか紹介します。
Embind によるキャンバス
既存のプロジェクトを移植するのではなく、新しいプロジェクトを開始する場合は、Emscripten のバインディング システム Embind を介して HTML Canvas API を使用するのが最も簡単な方法です。Embind を使用すると、任意の JavaScript 値を直接操作できます。
Embind の使い方を理解するには、まず次の MDN の例をご覧ください。<canvas> 要素を見つけてその要素に図形を描画しています。
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);
Embind を使用して C++ に文字変換する方法は次のとおりです。
#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
次に、コンパイルしたアセットを静的サーバーで提供し、サンプルをブラウザで読み込むことができます。
キャンバス要素の選択
前のシェルコマンドと Emscripten 生成の HTML シェルを使用すると、キャンバスが含まれ、設定されます。これにより、シンプルなデモや例を簡単に作成できますが、大規模なアプリケーションでは、Emscripten で生成された JavaScript と WebAssembly を独自に設計した HTML ページに含める必要があります。
生成される JavaScript コードは、Module.canvas
プロパティに格納されているキャンバス要素を検出することを想定しています。Module の他のプロパティと同様に、初期化時に設定できます。
ES6 モード(拡張子が .mjs
のパスに出力を設定するか、-s EXPORT_ES6
設定を使用する)を使用している場合は、次のようにキャンバスを渡すことができます。
import initModule from './emscripten-generated.mjs';
const Module = await initModule({
canvas: document.getElementById('my-canvas')
});
通常のスクリプト出力を使用している場合は、Emscripten で生成された JavaScript ファイルを読み込む前に Module
オブジェクトを宣言する必要があります。
<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 を介してオーディオ、キーボード、マウス、ジョイスティック、グラフィック ハードウェアに低レベルでアクセスできるように設計されたクロス プラットフォーム開発ライブラリです。
音声、キーボード、マウス、グラフィックの操作など、これらの機能はすべて移植されており、Web 上の 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 は、すでに WebAssembly にプリコンパイルされている SDL2 ライブラリを取得し、メイン アプリケーションとリンクするように指示されます。
emcc example.cpp -o example.html -s USE_SDL=2
サンプルがブラウザに読み込まれると、おなじみの緑色の長方形が表示されます。
ただし、このコードにはいくつかの問題があります。まず、割り振られたリソースが適切にクリーンアップされません。2 つ目は、ウェブではアプリケーションの実行が終了してもページが自動的に閉じられないため、キャンバス上の画像が保持されることです。ただし、Python コードで同じコードをネイティブに再コンパイルすると、
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 x 300 のウィンドウが表示されます。
ただし、この例はウェブでは動作しなくなりました。Emscripten で生成されたページは読み込み中にすぐにフリーズし、レンダリングされた画像は表示されません。
どうなりましたか?WebAssembly からの非同期ウェブ API の使用に関する記事からの答えを引用します。
簡潔に言うと、ブラウザはすべてのコードをキューから 1 つずつ取得して、無限ループのように実行します。イベントがトリガーされると、ブラウザは対応するハンドラをキューに入れます。次のループの反復処理で、そのハンドラはキューから取り出されて実行されます。このメカニズムにより、1 つのスレッドのみを使用して、同時実行をシミュレートし、多数の並列オペレーションを実行できます。
このメカニズムについて覚えておくべき重要な点は、カスタム JavaScript(または WebAssembly)コードの実行中に、イベントループがブロックされることです [...]
上記の例では無限イベントループが実行されますが、コード自体は、ブラウザによって暗黙的に提供される別の無限イベントループ内で実行されます。内部ループは外部ループに制御を放棄しないため、ブラウザは外部イベントを処理したり、ページに描画したりできません。
この問題を修正する方法は 2 つあります。
Asyncify によるイベントループのブロック解除
まず、リンク先の記事で説明されているように、Asyncify を使用できます。これは Emscripten の機能で、C または C++ プログラムを「一時停止」し、イベントループに制御を戻し、非同期オペレーションが完了したときにプログラムをウェイクアップできます。
このような非同期オペレーションは、emscripten_sleep(0)
API で表す「可能な最小時間スリープ」にすることもできます。ループの途中に埋め込むことで、反復処理のたびにコントロールがブラウザのイベントループに返され、ページの応答性が維持され、あらゆるイベントを処理できるようになります。
#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
では、コールスタックの巻き戻しと巻き戻しを行うためのコンパイラ変換は必要ないため、コードサイズのオーバーヘッドを回避できます。その代わり、手動でコードに変更を加える必要があります。
まず、イベントループの本体を別の関数に抽出する必要があります。次に、その関数とともに、1 つ目の引数にコールバック、2 つ目の引数に FPS(ネイティブの更新間隔の場合は 0
)、3 つ目の引数で無限ループをシミュレートするかどうかを示すブール値(true
)として、emscripten_set_main_loop
を呼び出す必要があります。
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
この例は最初のバージョンとまったく同じように機能します。最初のバージョンでは、コードの方がはるかにシンプルであるにもかかわらず、キャンバスに長方形が正常に描画され、handle_events
関数で処理される唯一の SDL_QUIT
イベントがウェブで無視されるため、まったく役に立たないかもしれません。
ただし、なんらかのアニメーションやインタラクティビティを追加する場合は、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 で実行した結果は次のとおりです。
ウェブの場合:
その他のグラフィック プリミティブについては、自動生成されたドキュメントをご覧ください。