Emscripten でのキャンバスへの図形描画

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 生成の HTML シェルを使用すると、キャンバスが含まれ、設定されます。これにより、シンプルなデモや例を簡単に作成できますが、大規模なアプリケーションでは、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')
});

通常のスクリプト出力を使用している場合は、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 を介してオーディオ、キーボード、マウス、ジョイスティック、グラフィック ハードウェアに低レベルでアクセスできるように設計されたクロス プラットフォーム開発ライブラリです。

音声、キーボード、マウス、グラフィックの制御など、すべての機能が移植され、ウェブ上の 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

サンプルがブラウザに読み込まれると、おなじみの緑色の長方形が表示されます。

黒い正方形のキャンバスに緑色の長方形を表示する、Emscripten で生成された HTML ページ。

ただし、このコードにはいくつかの問題があります。まず、割り振られたリソースが適切にクリーンアップされません。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 x 300 のウィンドウが表示されます。

黒い背景と緑色の長方形の正方形の Linux ウィンドウ。

ただし、この例はウェブでは動作しなくなりました。Emscripten で生成されたページは読み込み中にすぐにフリーズし、レンダリングされた画像は表示されません。

Emscripten で生成された HTML ページに、「ページが応答しない」エラー ダイアログが重ねて表示され、ページが応答するまで待つか、ページを閉じるように促す

どうなりましたか?「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

ウェブでアプリケーションが想定どおりに動作するようになりました。

黒い正方形のキャンバスに緑色の長方形を表示する、Emscripten で生成された HTML ページ。

ただし、Asyncify ではコードサイズのオーバーヘッドが大幅に増加する可能性があります。アプリの最上位イベントループにのみ使用される場合は、emscripten_set_main_loop 関数を使用することをおすすめします。

「メインループ」API によるイベントループのブロック解除

emscripten_set_main_loop では、コールスタックの巻き戻しと巻き戻しを行うためのコンパイラ変換は必要ないため、コードサイズのオーバーヘッドを回避できます。ただし、その代わりに、コードを手動で変更する必要がある場合が多くなります。

まず、イベントループの本体を別の関数に抽出する必要があります。次に、emscripten_set_main_loop を呼び出す際に、最初の引数でコールバックとしてその関数を指定し、2 番目の引数で FPS(ネイティブの更新間隔の場合は 0)、3 番目の引数で無限ループをシミュレートするかどうかを示すブール値を指定する必要があります。true

emscripten_set_main_loop(callback, 0, true);

新しく作成されたコールバックは、main 関数内のスタック変数にアクセスできないため、windowrenderer などの変数をヒープ割り当て構造体に抽出し、そのポインタを 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 で実行した場合の結果は次のとおりです。

黒い背景と緑色の円がある正方形の Linux ウィンドウ。

ウェブの場合:

黒い正方形のキャンバスに緑色の円を表示する、Emscripten 生成の HTML ページ。

その他のグラフィック プリミティブについては、自動生成ドキュメントをご覧ください。