WebGL
Emscripten

Emscriptenでレンダリングスレッドを作る

レンダリングスレッドとは

DirectXやOpenGLといったグラフィックスAPIを叩く処理を行うスレッドのことです。
メインスレッドとは別のスレッドで行うことで、ゲームプログラミングの世界ではCPUの負荷分散を目的に行われています。

Emscriptenスレッド対応

今まではEmscriptenでスレッドは使用できなかったのですが、新機能のSharedArraryBufferを活用してスレッドが使えるようになりました。

※なお、WebAssemblyではスレッドはまだ使えません。今のところasm.jsのみです。残念…

WebGLのWebWorker対応

WebGLをWebWorkerから使うことも新機能のOffscreenCanvasを使うことで可能になったようです。

組み合わせてみる

これらの機能を組み合わせればレンダリングスレッドを実現できるんじゃないか
と思い、Emscriptenのスレッド周りを調べつつ色々試してみました。

今回も黒魔術成分多めでお送りいたします。

環境

  • Emscripten SDK 1.37.9
  • Windows 10
  • Chrome 63.0
  • Firefox 57.0.2

スレッドを使う

EmscriptenではExperimentalな機能ですが、以下の指定でスレッドを使用することができます。

emcc -std=c++11 -s USE_PTHREADS=1 -s PTHREAD_POOL_SIZE=2 main.cpp

(-s PTHREAD_POOL_SIZE には同時に使用するスレッドの数を指定します)

これでスレッドが使えるようになり、C++11の以下のAPIなども使えるようになります。

  • std::thread
  • std::mutex
  • std::condition_variable
example.cpp
#include <stdio.h>
#include <thread>

int main()
{
  std::thread t([]{ printf("hello by thread.\n"); });
  t.join();
  return 0;
}
output
Preallocating 2 workers for a pthread spawn pool.
hello by thread.

OffscreenCanvasを使う

WebWrokerでCanvas 2D描画やWebGLを使える素晴らしいAPIです。
しかし…

https://caniuse.com/#search=OffscreenCanvas
offscreencanvas.png

2017年12時点、まだまだExperimentalの機能なので、使うにはブラウザのフラグを有効にする必要があります。

別スレッドのWorkerにOffscreenCanvasを渡す

まず、普通のcanvasをtransferControlToOffscreen()してOffscreenCanvas化します。
次にWorkerでOffscreenCanvasを使うため、transferable的にpostMessageします。
このとき使うWorkerですが、PThread.pthreads[pthread_t]で取れるオブジェクトに含まれていました。

Main側
renderThread = std::thread(...);
EM_ASM_({
    // CanvasをOffscreenCanvasに変換
    var canvas = document.getElementById("canvas");
    var offscreen = canvas.transferControlToOffscreen();
    // OffscreenCanvasを描画スレッドのWorkerに送る
    PThread.pthreads[$0].worker.postMessage({cmd: "objectTransfer", event: "init", canvas: offscreen}, [offscreen]);
}, (int)renderThread.native_handle());  // 描画スレッドのpthread_tを渡す

objectTransferというのは、pthreadが生成したWorkerのonmessageで記述されているコマンドです。
今回はイケないコトですがこれを乗っ取りOffscreenCanvasを渡すのに使用しました。
(実際には空関数がセットされていたので、へーきへーき)

Worker側
PThread.receiveObjectTransfer = function(data){  // 上書き
  var offscreen = data.canvas;  // OffscreenCanvas
};

OffscreenCanvasからOpenGLコンテキストを作成する

C++からOpenGL APIを叩くための準備をします。
Workerに渡されたOffscreenCanvasからWebGLRenderingContextを取得して、OpenGL Contextを作成します。

Thread側
void threadFunc()
{
    EM_ASM_({
        // receiveObjectTransferを乗っ取る
        PThread.receiveObjectTransfer = function(data){
            if (data.event == "init") {
                // 渡されたOffscreenCanvasからWebGLRenderingContextを取得
                var webglctx = data.canvas.getContext("webgl");
                // Emscripten OpenGLのContextを作成
                var glctx = GL.registerContext(webglctx, {
                    majorVersion: "1", minorVersion: "0",   // WebGLバージョン
                    enableExtensionsByDefault: true         // 拡張APIをON
                });
                GL.makeContextCurrent(glctx);
                // initを呼び出す
                Runtime.dynCall("v", $0, []);
            }
            // スレッドが終了してもWorkerが回収されないようにする
            Module['noExitRuntime'] = true;
        };
    }, &init, &render); // JavaScriptから呼んでもらう関数ポインタを指定
}

JavaScript側からOpenGLのコンテキスト作成に関する詳しい解説は、以下のスライドを見てください。

さて、黒魔術が濃くなってきました。

Main側から描画をリクエストする

emscripten_set_main_loopの第2引数に0を指定すると、requestAnimationFrameイベントでコールバックが得られます。
このコールバックが来たら描画が必要ということを描画スレッドに通知します。
(なんか違うような気がしますが、苦し紛れにobjectTransferを使用しました。)

Main側
emscripten_set_main_loop([](){
    EM_ASM_({
        // requestAnimationFrameイベントで描画命令を描画スレッドのWorkerに通知
        PThread.pthreads[$0].worker.postMessage({cmd: "objectTransfer", event: "render"});
    }, (int)renderThread.native_handle());  // 描画スレッドのpthread_tを渡す
}, 0, false);

描画命令が来たらOpenGLで描画を行います。

Thread側
void render()
{
    // 色々省略
    glUseProgram(program);
    glDrawArrays(GL_TRIANGLES, 0, 3);
}
void threadFunc()
{
    EM_ASM_({
        // receiveObjectTransferを乗っ取る
        PThread.receiveObjectTransfer = function(data){
            if (data.event == "init") {
                // (省略)
            } else if (data.event == "render") {
                // renderを呼び出す
                Runtime.dynCall("v", $1, []);
                // OffscreenCanvasのWebGLの描画完了を通知
                GLctx.commit();
            }
            // スレッドが終了してもWorkerが回収されないようにする
            Module['noExitRuntime'] = true;
        };
    }, &init, &render); // JavaScriptから呼んでもらう関数ポインタを指定
}

今回 eglSwapBuffers といった同期待ち関数は使えません。
代わりにGLctx.commit();を呼んで、スレッド側の描画完了をMain側のCanvasに知らせます。

出来上がったコード

描画部分は、ごく普通のOpenGL ES 2.0なコードです。

rendering.cpp
#include <stdint.h>
#include <GLES2/gl2.h>
#include "glbook/esUtil.h"

GLuint program = 0; // シェーダ
GLuint buffer = 0;  // VBO
float angle = 0.0f;

// 頂点フォーマット
struct Vertex {
    float x, y;     // 頂点位置
    uint32_t color; // 頂点色
};

const char gVertexShader[] =
    "attribute vec4 a_Position;\n"
    "attribute vec4 a_Color;\n"
    "varying vec4 v_Color;\n"
    "uniform mat4 u_ModelMat;\n"
    "void main() {\n"
    "  gl_Position = a_Position * u_ModelMat;\n"
    "  v_Color = a_Color;\n"
    "}\n";


const char gFragmentShader[] =
    "precision mediump float;\n"
    "varying vec4 v_Color;\n"
    "void main() {\n"
    "  gl_FragColor = v_Color;\n"
    "}\n";

// 初期化
void init() {
    // シェーダビルド
    program = esLoadProgram(gVertexShader, gFragmentShader);

    // 頂点データ
    const Vertex vertices[] = {
         0.0f,  0.5f, 0xff0000ff,
        -0.5f, -0.5f, 0xff00ff00,
         0.5f, -0.5f, 0xffff0000,
    };
    // VBO作成
    glGenBuffers(1, &buffer);
    glBindBuffer(GL_ARRAY_BUFFER, buffer);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    glBindBuffer(GL_ARRAY_BUFFER, 0);
}

// 描画
void render() {
    glClearColor(0.3f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    ESMatrix modelMat;
    esMatrixLoadIdentity(&modelMat);
    esRotate(&modelMat, angle, 0.0f, 1.0f, 0.0f);
    angle += 5.0f;

    GLuint positionLoc = glGetAttribLocation(program, "a_Position");
    GLuint colorLoc = glGetAttribLocation(program, "a_Color");
    GLuint modelMatLoc = glGetUniformLocation(program, "u_ModelMat");

    glUseProgram(program);
    glUniformMatrix4fv(modelMatLoc, 1, GL_FALSE, &modelMat.m[0][0]);
    glBindBuffer(GL_ARRAY_BUFFER, buffer);
    glVertexAttribPointer(positionLoc, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
    glVertexAttribPointer(colorLoc, 4, GL_UNSIGNED_BYTE, GL_TRUE, sizeof(Vertex), (void*)8);
    glEnableVertexAttribArray(positionLoc);
    glEnableVertexAttribArray(colorLoc);
    glDrawArrays(GL_TRIANGLES, 0, 3);
}

続きまして、描画関数をスレッドから呼び出すために色々試行錯誤した部分です。

main.cpp
#include <thread>
#include <emscripten.h>
#include "rendering.h"

std::thread renderThread;

void threadFunc()
{
    EM_ASM_({
        // receiveObjectTransferを乗っ取る
        PThread.receiveObjectTransfer = function(data){
            if (data.event == "init") {
                // 渡されたOffscreenCanvasからWebGLRenderingContextを取得
                var webglctx = data.canvas.getContext("webgl");
                // Emscripten OpenGLのContextを作成
                var glctx = GL.registerContext(webglctx, {
                    majorVersion: "1", minorVersion: "0",   // WebGLバージョン
                    enableExtensionsByDefault: true         // 拡張APIをON
                });
                GL.makeContextCurrent(glctx);
                // initを呼び出す
                Runtime.dynCall("v", $0, []);
            } else if (data.event == "render") {
                // renderを呼び出す
                Runtime.dynCall("v", $1, []);
                // OffscreenCanvasのWebGLの描画完了を通知
                GLctx.commit();
            }
            // スレッドが終了してもWorkerが回収されないようにする
            Module['noExitRuntime'] = true;
        };
    }, &init, &render); // JavaScriptから呼んでもらう関数ポインタを指定
}

int main()
{
    // Canvasのサイズをセット
    emscripten_set_canvas_size(640, 480);

    // 描画スレッドを作成
    renderThread = std::thread(threadFunc);

    EM_ASM_({
        // CanvasをOffscreenCanvasに変換
        var canvas = document.getElementById("canvas");
        var offscreen = canvas.transferControlToOffscreen();
        // OffscreenCanvasを描画スレッドのWorkerに送る
        PThread.pthreads[$0].worker.postMessage({cmd: "objectTransfer", event: "init", canvas: offscreen}, [offscreen]);
    }, (int)renderThread.native_handle());  // 描画スレッドのpthread_tを渡す

    emscripten_set_main_loop([](){
        EM_ASM_({
            // requestAnimationFrameイベントで描画命令を描画スレッドのWorkerに通知
            PThread.pthreads[$0].worker.postMessage({cmd: "objectTransfer", event: "render"});
        }, (int)renderThread.native_handle());  // 描画スレッドのpthread_tを渡す
    }, 0, false);

    return 0;
}

なんだこれは、ほとんどJavaScriptじゃないか…(呆れ)

これらのソースコードをビルドします。
GLBookのESライブラリを使用したので、一緒にビルドします。

ビルドコマンド
emcc -std=c++11 -s USE_PTHREADS=1 -s PTHREAD_POOL_SIZE=2 glbook/esShader.cpp glbook/esTransform.cpp glbook/esUtil.cpp rendering.cpp main.cpp -lpthread -o index.html

実行

デモページ

gles.png

なんとかWorker側でレンダリングが行うことができました。
上手く動かない場合はOffscreenCanvas関連のフラグをONにしてください。

EdgeやSafariだと動かないと思います。早く対応してぇ

おわりに

OffscreenCanvasは現状Experimentalですが、WebAssemblyのスレッド対応と併せて
2018年中には普通に使えるようになるんじゃないかと思います(たぶん)

別スレッドでレンダリングすることはメインスレッドのGCを避けることができるメリットもあります。
そうなったらUnityもサポートするだろうし、ブラウザのゲームがぬるぬる動くようになる未来も夢じゃないです。わいわい!