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 プロパティに格納されているキャンバス要素を検出することを想定しています。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

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

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

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

黒い背景と緑色の長方形の正方形の 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 では、コールスタックの巻き戻しと巻き戻しを行うためのコンパイラ変換は必要ないため、コードサイズのオーバーヘッドを回避できます。その代わり、手動でコードに変更を加える必要があります。

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

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 ページ。

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