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

次に、コンパイルしたアセットを静的サーバーで配信し、サンプルをブラウザで読み込みます。

黒いキャンバスの上に緑色の長方形が表示されている、エミュレータが生成した 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 を使用する必要があります。これにより、すでに WebAssembly にプリコンパイルされている SDL2 ライブラリを取得してメイン アプリケーションにリンクするように Emscripten に指示します。

emcc example.cpp -o example.html -s USE_SDL=2

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

黒い正方形のキャンバスの上に緑色の長方形が表示されている、エミュレータが生成した HTML ページ。

ただし、このコードにはいくつか問題があります。まず、割り当てられたリソースを適切にクリーンアップできません。2 つ目は、ウェブの場合、アプリケーションの実行が完了してもページが自動的に閉じられることはないため、キャンバス上の画像は保持されます。ただし、同じコードを .tf ファイルを使用してネイティブに再コンパイルすると、

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

また、アプリケーションはウェブでも想定どおりに機能します。

黒い正方形のキャンバスの上に緑色の長方形が表示されている、エミュレータが生成した HTML ページ。

ただし、Asyncify にはコードサイズのオーバーヘッドが大きく発生する可能性があります。アプリのトップレベルのイベントループにのみ使用する場合は、emscripten_set_main_loop 関数を使用することをおすすめします。

「メインループ」API を使用したイベントループのブロック解除

emscripten_set_main_loop は、コールスタックのアンワインドと巻き戻しのためにコンパイラ変換を必要としないため、コードサイズのオーバーヘッドが回避されます。ただし、その代わりとして、コードを手動で何度も変更する必要があります。

まず、イベントループの本文を別の関数に抽出する必要があります。次に、その関数を最初の引数としてコールバック、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 は、クロス プラットフォームの違いやさまざまな種類のメディア デバイスを 1 つの 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 ウィンドウ。

ウェブの場合:

黒い正方形のキャンバスに緑色の円が表示されている、エミュレータが生成した HTML ページ。

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