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

Emscripten を使用して WebAssembly からウェブ上で 2D グラフィックをレンダリングする方法を学びます。

オペレーティング システムによって、グラフィックを描画するための API は異なります。クロス プラットフォーム コードを記述する場合や、ネイティブ コードを WebAssembly に移植する場合など、あるシステムから別のシステムにグラフィックを移植する場合、その違いはさらに複雑になります。

この投稿では、Emscripten でコンパイルされた C / C++ コードから、ウェブ上のキャンバス要素に 2D グラフィックを描画するための方法をいくつか紹介します。

Embind によるキャンバス

既存のプロジェクトを移植するのではなく、新しいプロジェクトを開始する場合は、Emscripten のバインディング システム Embind を介して HTML Canvas API を使用する方が簡単な場合があります。Embind を使用すると、任意の JavaScript 値を直接操作できます。

Embind の使用方法を理解するには、まず、<canvas> を検索する次の MDN の例をご覧ください。その上に図形を描画します。

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 を使用する必要があります。これにより、すでに WebAssembly にプリコンパイルされている SDL2 ライブラリを取得し、メイン アプリケーションとリンクするよう Emscripten に指示します。

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 の機能イベントループに制御を戻し、非同期処理が終了したときにプログラムを復帰させます。

このような非同期オペレーションは、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 ページ。

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