レンダリングスレッドとは
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
#include <stdio.h>
#include <thread>
int main()
{
std::thread t([]{ printf("hello by thread.\n"); });
t.join();
return 0;
}
Preallocating 2 workers for a pthread spawn pool.
hello by thread.
OffscreenCanvasを使う
WebWrokerでCanvas 2D描画やWebGLを使える素晴らしいAPIです。
しかし…
https://caniuse.com/#search=OffscreenCanvas
2017年12時点、まだまだExperimentalの機能なので、使うにはブラウザのフラグを有効にする必要があります。
- Chrome: chrome://flags/#enable-experimental-canvas-featuresを有効にする
- Firefox: about:config の gfx.offscreencanvas.enabled を true にする。
別スレッドのWorkerにOffscreenCanvasを渡す
まず、普通のcanvasをtransferControlToOffscreen()してOffscreenCanvas化します。
次にWorkerでOffscreenCanvasを使うため、transferable的にpostMessageします。
このとき使うWorkerですが、PThread.pthreads[pthread_t]で取れるオブジェクトに含まれていました。
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を渡すのに使用しました。
(実際には空関数がセットされていたので、へーきへーき)
PThread.receiveObjectTransfer = function(data){ // 上書き
var offscreen = data.canvas; // OffscreenCanvas
};
OffscreenCanvasからOpenGLコンテキストを作成する
C++からOpenGL APIを叩くための準備をします。
Workerに渡されたOffscreenCanvasからWebGLRenderingContextを取得して、OpenGL Contextを作成します。
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を使用しました。)
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で描画を行います。
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なコードです。
#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);
}
続きまして、描画関数をスレッドから呼び出すために色々試行錯誤した部分です。
#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
実行
なんとかWorker側でレンダリングが行うことができました。
上手く動かない場合はOffscreenCanvas関連のフラグをONにしてください。
EdgeやSafariだと動かないと思います。早く対応してぇ
おわりに
OffscreenCanvasは現状Experimentalですが、WebAssemblyのスレッド対応と併せて
2018年中には普通に使えるようになるんじゃないかと思います(たぶん)
別スレッドでレンダリングすることはメインスレッドのGCを避けることができるメリットもあります。
そうなったらUnityもサポートするだろうし、ブラウザのゲームがぬるぬる動くようになる未来も夢じゃないです。わいわい!