😭

EmscriptenでCMakeとかOpenGLとか (2023年9月版)

2023/10/04に公開

はじめに

C/C++をWebAssemblyへコンパイルしてくれる楽しい楽しいコンパイル環境Emscripten。C++で描いたアプリケーションをWebで実行できるので、C++使いにとっては楽しい環境です。

筆者はEmscriptenに過去何度か挑戦しています。前回挑戦したのは2019年ごろで、その時にはまだ、SharedArraybufferの利用が難しく、スレッドを移植しても、一般的に実行が難しい状況でした。また、WebAssemblyの例外処理機構もブラウザでサポートされていなかったため、例外を使うと純粋なWebAssemblyになってくれませんでした(そもそも、WebAssemblyにコンパイルするとバイナリサイズが膨大になっていた気が…)。そのため、その時には調べはしたものの、まだ早いなという結論に至っていました。

時は2023年、現在筆者が作っているGUIライブラリを改めてWeb化してみようとEmscriptenとブラウザの状況を調べてみたところ、以前躓いていた環境はすっかり改善され、手元のC++資産がほぼそのままWeb化できそうな環境が整っていました。その一方で、中~大規模の自作プログラムをビルドする際のノウハウ(特に日本語記事)がなかなか見つかりませんでした。そこで、筆者が2023年9月に調べたEmscriptenのノウハウをここに残そうと思います。

トピック

この記事のトピックは以下の通りです。

  • CMakeを使ったのビルドとそのノウハウ
    • Pthreadの利用
    • WebAssembly Exceptionを使った例外処理の利用
  • OpenGL ES(ブラウザ上ではWebGL)の生初期化
  • iOSのSafariで動かない!

なお、この記事では、Emscripten自体や、MinGWやCMakeといったC++で開発する際によく使うツールに関しては、詳しい説明を省いています。

CMakeを使ったのビルドとそのノウハウ

今やC++ビルドのデファクトCMake。CMakeLists.txtにシンプルな記法でビルド設定を書くだけで、クロスプラットフォームのコンパイルが行える便利ツールです。ある程度の規模のアプリケーションを開発する場合は、多くの方がCMakeを利用することでしょう。

EmscriptenでCMakeを利用する場合は、CMakeの前にemcmakeをつけると利用できます。emcmakeはCMakeにEmscripten用の適切な環境設定を行ってくれるツールだそうです。emcmakeをつけてCMakeを実行すると、Emsciptenを使ったビルド用にMakeFileを作成してくれます、あとはMakeすれば無事にビルドできます。

ただし、Windowsの場合は、標準でVisual Studioのソリューションを作成してしまします。そのため、ジェネレータにMakeFileを作るように指定する必要があります。以下の例は、Windows環境でMinGWを利用した場合のものです。

emcmake cmake ./ -G "MinGW Makefiles"

ここまでは、まぁ、Webを調べればたくさん情報が出てきます。

さて、いざ自分で書いたCMakeのプロジェクトをビルドすると、いろいろなトラブルに見舞われました。しかし、解決策をWebで調べても、なかなか良い文献が見つかりませんでした。後述するこの記事の例と同様、小さなプロジェクトのビルドを試してみた(日本語の)記事はそれなりに見つかるのですが、大き目のプロジェクトに関しての記事は思ったより見つかりませんでした。そのため、この記事の執筆に至りました。

リンカーへのオプション指定

大規模なプロジェクトは当然、.aファイルなどにスタティックライブラリをコンパイルして、それをリンクして最終的に仕上げるような状況がほとんどです。また、CMakeを使った場合は、コンパイルオプションとリンカーオプションを明確に分けて意識することになります。

Emscripten&CMakeで最も参考になったのは、こちらの記事でした(ありがとうございます!)。
こちらの記事では、CMakeでのハマりポイントが解説されています。

Emscriptenのリンカーのオプションには-s XXXXというものがたくさんあるのですが、先の記事によると

target_link_optionsで指定するやつですね。実はCMakeさんには共通のオプションを勝手にまとめるというとんでもない迷惑仕様が存在します。したがって上記オプションの-sが一つにまとめられてしまい、ビルドが通らなくなります。
仕様がイマイチなら解決策もイマイチで、なんとオプションを文字列化して、先頭にSHELL:をつけると省略されなくなるという、非常にイケてないワークアラウンドが実装されています。

という解説があり、確かに、その通りにしないとうまくいきませんでした。

私なりに参考にさせていただきながら試していたところ、どうやら-s XXXX-sの後にスペースを入れると、先のような仕様が発動するようで、-sXXXXとスペースを空けなければ、問題なく指定できました。これで、ずいぶんと記述をシンプルにできます。

if()~endif()でEmscriptenを判断するには?

Emscirpten用にコンパイル/リンカーのオプションを指定しなければいけないということは、CMakeの中でEmscripten用に分岐をしたくなるわけです。

調べてみたところ、emcmakeを使うとEMSCRIPTENという変数?が定義されるようで、こちらで分岐ができました。

# こんな感じで分岐
if(EMSCRIPTEN)
  # これで、~~.htmlを出力できるようになる
  set_property(TARGET opengl_test PROPERTY SUFFIX ".html")
endif()

ちなみに、私、それほどCMakeに詳しいわけではありませんので、そもそもifで分岐するのはお作法ではないよ!もっといい方法があるよ!という方いらっしゃったらご指摘いただけると幸いです。

Pthread(std::thread)を有効にするときの注意

現在のブラウザのほとんどがSharedArraybufferが利用できます。Emscriptenは、SharedArraybufferとWebWorkerを利用してPthread/std::threadをコンパイルしてくれるため、スレッドを利用したC++プログラムをそのままWeb化できます(いい時代)。

さて、Emscripitenの公式サイトによると、-pthreadをコンパイラーフラグにつければOKよ!と書かれているため、CMakeにadd_compile_options("-pthread")を追加して、ビルドしてみます。すると、

[wasm-validator error in function int\20std::__2::__cxx_atomic_load\5babi:v160004\5d<int>\28std::__2::__cxx_atomic_base_impl<int>\20const*\2c\20std::__2::memory_order\29] unexpected false: call target must exist, on

みたいなビルドエラーが発生します。__cxx_atomic_base_impl辺りで怒っているのでスレッド周りっぽいですよね。

この場合、-pthreadをリンカーにも追加すると解決します。コンパイルオプションとリンカーオプションの両方に追加しないとうまくいきません。

もう一点、リンカーの-sPTHREAD_POOL_SIZEオプションもつけないと怒られました。ここには、ワーカーをいくつ作るかを指定できnavigator.hardwareConcurrencyと式を書くこともできるようです。ただ、navigator.hardwareConcurrencyを入れるとコア数の多い環境でたくさんのワーカーが作られるためか、ロードに無駄に時間がかかるようになるため、4くらいを指定しておくと良さそうです。

ちなみに、スレッドが有効な場合は、コンパイル環境に__EMSCRIPTEN_PTHREADS__が定義されます。C++のコード内で#ifdefで判定する場合は、このシンボルが便利です。

WebAssembly Exceptionを使った例外処理を有効にするときの注意

Emscriptenは、WebAssemblyの例外バンドリングを使ってC++の例外機構をコンパイルしてくれます(公式サイト)。WebAssemblyの例外バンドリングは、最近の主要ブラウザであれば問題なく利用できるようです。

さて、有効にする方法はPthread同様に-fwasm-exceptionsを追加するだけです。こちらも、コンパイルオプションとリンカーオプションの両方に追加しないとうまくいきません。
もし、リンカーに追加しないと、

undefined symbol: __wasm_lpad_context
_Unwind_CallPersonality

などのビルドエラーが発生します。この辺りが見つかった場合には、リンカーオプションを疑うと良いと思います。

OpenGL ES(ブラウザ上ではWebGL)の生初期化

Emscriptenを想定する場合、SDLやGLFWを使って開発するのが一般的なのでしょうが、ツールキット作りたい屋さんからすると、外部のフレームワークに頼らずに作りたいものです。

話はそれますが、クロスプラットフォームのツールキットを作る場合、筆者は描画APIとしてOpenGL ES (3.0)をおすすめします。Androidでは標準ですし、デスクトップでは、Googleのangleというオープンソースプロジェクトを利用すればOpenGL ESが綺麗に利用できます(もちろん、Mac/iOSではMetalでも動きます)。ブラウザのWebGL2はOpenGL ES 3.0相当です。そもそもangleは、Open GL ES非対応環境のブラウザでWebGLを実装するために作られたライブラリです。OpenGL ESを前提にしておけば、描画APIのほか、シェーダープログラムも各環境で共用できるため、移植性が非常に高まります。

さて、話は戻ってEmscriptenです。OpenGL ESを使うには、各環境でコンテキストを初期化するために、EGLというAPIを利用します。Emscriptenは、幸い、EGLに対応しています。私のGUIフレームワークでは、他の環境(Windows/OSX)もこのEGLを利用しており、Emscripten環境でもソースコードを流用したいため、EmscriptenでEGLを使った初期化の実験をしてみました。

やり方は、公式サイトに紹介されている手順そのもので問題ありませんが、シンプルなサンプルコードが見つからなかったため、この記事で紹介します。下記のコードが、OpenGL ES 3.0の初期化処理コードです。見やすくするために、エラー処理などをすべて省いています。

EGLConfig config;
eglDisplay = eglGetDisplay((EGLNativeDisplayType)EGL_DEFAULT_DISPLAY);
EGLint major, minor;
auto ret = eglInitialize(eglDisplay, &major, &minor);
ret = eglBindAPI(EGL_OPENGL_ES_API);
EGLint configAttributes[] = {
    EGL_RED_SIZE, 8,
    EGL_GREEN_SIZE, 8,
    EGL_BLUE_SIZE, 8,
    EGL_ALPHA_SIZE, 8,
    EGL_DEPTH_SIZE, 24,
    EGL_STENCIL_SIZE, 8,
    EGL_NONE};
EGLint num_config;
ret = eglChooseConfig(eglDisplay, configAttributes,
                      &config, 1,
                      &num_config);
EGLint contextAttributes[] = {
    EGL_CONTEXT_MAJOR_VERSION, 3,
    // EGL_CONTEXT_MINOR_VERSION, 0, -> マイナーバージョンを入れると失敗する
    EGL_NONE};

eglContext = eglCreateContext(eglDisplay,
                      config, EGL_NO_CONTEXT, contextAttributes);
ret = eglMakeCurrent(eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, eglContext);
// 第3引数のネイティブウィンドウハンドルにはNULLを渡す。
surface = eglCreateWindowSurface(eglDisplay, config, NULL, NULL);

ポイント(といっても公式で明記されていますが)は、eglCreateWindowSurfaceの第3引数にNULLを渡すところです。もう一つのポイントは、上のサンプルコードのcontextAttributesには、メジャーバージョンのみを渡すことです。マイナーバージョンを入れると、コンテキストの生成に失敗しました。

ちなみに、OpenGLの描画結果はHTMLのCanvas要素に描画されるわけですが、どのCanvasに描画されるかは、HTML(JavaScript)のグローバル変数Modulecanvasプロパティによるようです。この辺りは、GLFWなどを使った場合と同じです。

あとは、リンカーオプションに-sFULL_ES3を追加すれば、Open GL ES 3.0のコードが(だいたい)うまく動いてくれます。ここは、コンパイラーのオプションは必要ありません。

ちなみに、Open GL ES 3.0の機能を使っていなくても、3.0を使う(WebGL 2を使う)ことが推奨されています。どうもWebGL 2の方が速いらしいです。

iOSのSafariで動かない!

Emcriptenでビルドに成功すると、アプリケーションが驚くほど綺麗にブラウザで動いてくれるため感動します。デスクトップだけではなく、モバイルのブラウザでも同様です。

と、浮かれて色々と試していたところ、なぜかiOSのSafariで初期化に失敗する事態に遭遇しました。調べてみると、Emscriptenが作ってくれる.jsファイル内のWebAssembly.Memoryのところで例外が発生していました。

Emscriptenの標準ですと、WebAssembly.Memory呼び出し時の最大メモリーサイズが2GBなのですが、これが原因とのことで、いくつかトラブル報告が見つかりました(これとか)。そのため、リンカーオプションのsMAXIMUM_MEMORY1024mbなどに設定すると動いてくれました。

サンプルコード

この記事で紹介したCMakeとOpenGL初期化等のサンプルコードを掲載します。このプログラムでは、OpenGL ES 3.0を初期化し、バッファを単色でクリアします。クリアカラーは、別スレッドで変更する変数によります。

CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(wasm LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)

if(EMSCRIPTEN)
  add_compile_options("-pthread")
  add_compile_options("-fwasm-exceptions")
   
  add_executable(opengl_test opengl_test.cpp)
  set_property(TARGET opengl_test PROPERTY SUFFIX ".html")
  target_link_options(opengl_test PRIVATE 
    "-sALLOW_MEMORY_GROWTH=1"
    "-sALLOW_TABLE_GROWTH=1"
    "-sSAFE_HEAP=1"
    "-sFULL_ES3"
    "-pthread"
    "-fwasm-exceptions"
    #  "-sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency"
    "-sPTHREAD_POOL_SIZE=4"
    "-sMAXIMUM_MEMORY=1024mb"
    )
endif()
opengl_test.cpp
#include <emscripten.h>
#include <emscripten/html5.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>

#include <stdio.h>
#include <cassert>
#include <thread>
#include <atomic>
#include <cmath>

void *eglDisplay = nullptr;
void *eglContext = nullptr;
EGLSurface surface;
std::atomic_int counter = 0;
std::thread t;

/**
 * 繰り返し関数
 */
EM_BOOL one_iter(double time, void *userData)
{
  EGLBoolean ret;
  ret = eglMakeCurrent(
      eglDisplay,
      surface,
      surface,
      eglContext);

  if (!ret)
  {
    printf("Failed to eglMakeCurrent");
    return EM_FALSE;
  }

  glClearColor(0.5f + 0.5f * std::sin(counter/100.0f), 0, 0, 1);
  glClear(GL_COLOR_BUFFER_BIT);

  ret = eglSwapBuffers(
      eglContext,
      surface);

  if (!ret)
  {
    printf("Failed to eglSwapBuffers");
    return EM_FALSE;
  }
  return EM_TRUE;
}

/**
 * OpenGLの初期化とスレッドのテスト
*/
int main(int argc, char *argv[])
{

  EGLConfig config;
  eglDisplay = eglGetDisplay((EGLNativeDisplayType)EGL_DEFAULT_DISPLAY);
  EGLint major, minor;
  auto ret = eglInitialize(eglDisplay, &major, &minor);

  if (!ret)
  {
    printf("Error: eglInitialize\n");
    return 1;
  }
  printf("major %d, minor %d\n", major, minor);

  ret = eglBindAPI(EGL_OPENGL_ES_API);

  if (!ret)
  {
    printf("Error: eglBindAPI\n");
    return 1;
  }

  // config
  EGLint configAttributes[] = {
      EGL_RED_SIZE, 8,
      EGL_GREEN_SIZE, 8,
      EGL_BLUE_SIZE, 8,
      EGL_ALPHA_SIZE, 8,
      EGL_DEPTH_SIZE, 24,
      EGL_STENCIL_SIZE, 8,
      EGL_NONE};

  EGLint num_config;
  ret = eglChooseConfig(eglDisplay, configAttributes,
                        &config, 1,
                        &num_config);
  if (!ret)
  {
    printf("Error: eglChooseConfig\n");
    return 1;
  }

  // context
  EGLint contextAttributes[] = {
      EGL_CONTEXT_MAJOR_VERSION, 3,
      // EGL_CONTEXT_MINOR_VERSION, 0, -> マイナーバージョンを入れると失敗する
      EGL_NONE};

  eglContext =
      eglCreateContext(eglDisplay,
                       config, EGL_NO_CONTEXT, contextAttributes);
  if (!eglContext)
  {
    printf("Error: eglCreateContext\n");
    return 1;
  }

  ret = eglMakeCurrent(eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, eglContext);

  if (ret == 0)
  {
    printf("Error: eglMakeCurrent\n");
    return 1;
  }

  // 第3引数のネイティブウィンドウハンドルにはNULLを渡す。
  surface = eglCreateWindowSurface(eglDisplay, config, NULL, NULL);
  if (surface == EGL_NO_SURFACE)
  {
    printf("Error: eglCreateWindowSurface\n");
    return 1;
  }

  printf("Succeed\n");

  const char *verInfo = (const char *)glGetString(GL_VERSION);
  const char *renderder = (const char *)glGetString(GL_RENDERER);
  printf("=== Graphics OpenGLES ===\n");
  if (verInfo != NULL)
  {
    printf("Version: %s\n", verInfo);
  }
  if (renderder != NULL)
  {
    printf("Renderer: %s\n", renderder);
  }
  printf("=== ===\n");

  // スレッドのテスト
  t = std::move(std::thread([]{ 
      while(true)
      {
        counter++;
        std::this_thread::sleep_for(std::chrono::microseconds(100));
      }
    }
  ));

  // 例外のテスト
  try{
    throw std::runtime_error("例外だよ");
    printf("例外後\n");
  }catch(std::exception& e)
  {
    printf("%s\n", e.what());
  }

  emscripten_request_animation_frame_loop(one_iter, 0);

  return 0;
}

まとめ

この記事では、筆者が2023年9月に調べたEmscriptenの開発ノウハウをまとめました。現時点で、スレッドや例外機構など、C++アプリケーション開発に便利な機能がコンパイルでき、その実行環境も整っているため、C++アプリのWebでのデプロイが現実的な選択肢になっています。

記事では、CMakeを使ったビルド方法のノウハウのほか、GLFWなどの外部フレームワークを使わずOpenGL ESを初期化するサンプルコードも紹介しました。Emscripten対応も視野に入れたアプリケーション開発の何かの参考になれば幸いです。

Emscriptenは面白くホットな環境です。そのため、Webにはさまざまな記事がありますが、私が調べた範囲ですと、HelloWorld程度の簡単なプログラムのビルド方法や、既存のライブラリのビルドノウハウがほとんどでした。そのため、ちょっとした入門から一歩進んだノウハウを共有できればと思いこの記事を執筆しました。この記事が、中~大規模のプログラムの開発に何か貢献できれば幸いです。

Discussion