OpenSiv3D Linux版からWeb版への移植の難所攻略

# あらすじ

この記事は、OpenSiv3DのLinux版の実装をWeb版に移植する際、変更が必要となった項目と、それをOpenSiv3D Web版ではどのように対応したかをまとめた記事です。

- SIMD命令を使うコードを、SIMD命令が使えない環境でも動作するようにする
- 使用するグラフィックAPIをOpenGL 4.xからOpenGL ES3.0に切り替える
- マルチスレッド(並列)なコードを、setTimeoutを使って擬似マルチスレッド(並行)なコードにする

OpenSiv3D v0.4.3ではWeb版は非公式プロジェクトとして開発継続中、OpenSiv3D v0.6では実験的機能として公式プロジェクトに組み込まれる予定です。

- [OpenSiv3D for Web プロジェクトページ](https://siv3d.kamenokosoft.com/ja)
- [OpenSiv3D Web版を使ってブラウザで動くゲームを作ってみる (Visual Studio版)](https://qiita.com/nokotan/items/4a364d1464e34301322d)

- - -

この記事の立ち位置は、次に紹介するOpenSiv3DのWebブラウザ版を実装しようとする試みをまとめた記事の延長線上にあります。

- [OpenSiv3DのWebブラウザ版をEmscriptenで作ろうとした話][1]
- [Emscripten で OpenGL コードの Web 移植を試す][2]

# SIMDEの導入

WebAssemblyの仕様において、WebAssemblyでも固定長ビット(128bit)のSIMD命令を使えるようにするという提案がなされています[^simd-proposal]。しかし、2020年12月現在、WebAssembly SIMDに対応しているブラウザはChromeのみという状況[^simd-support]であり、WebAssembly SIMDを使うには早すぎる状況と考えられます。一方、WebAssembly SIMDの対応状況が進み、近い将来にWebAssembly SIMDを使っても問題ない状況が訪れるとも考えられます。

[^simd-proposal]: WebAssembly/simd, "WebAssembly 128-bit packed SIMD Extension", <https://github.com/WebAssembly/simd/blob/master/proposals/simd/SIMD.md>, 2020/12/ 7 閲覧

[^simd-support]: Chrome Support Status, "Feature: WebAssembly SIMD", <https://www.chromestatus.com/feature/6533147810332672>, 2020/12/ 7 閲覧

この2点から、次の2項目が必須条件となりました。

- WebAssembly SIMDを使わない実装のため、SIMD命令の代替実装を備えていること
- WebAssembly SIMDを使った実装に切り替えやすいこと

OpenSiv3D Web版では、この2項目を満たすライブラリとして、<https://github.com/simd-everywhere/simde> を採用しました。

# OpenGL ES3.0への切り替え

WebGL2はOpenGL ES3.0をベースに作成されており、emscriptenのJavaScirptライブラリを使用して、OpenGL ES3.0のほとんどのの機能をそのまま使用することができます。しかし、OpenGL 4.xとOpenGL ES3.0の機能の差異はかなり大きく、実装の変更を余儀なくされた部分も数多くありました。

次の機能はOpenGL 4.xでもOpenGL ES3.0でもほとんど差異なく扱えました。

- シェーダ定数 (Uniform Buffer Object)
- 頂点バッファの取り回し (Vertex Array Object)

一方、次の機能(関数)はOpenGL ES3.0には存在しません。

- Program Pipeline
- glPolygonMode
- glDrawElementsBaseVertex

## Program Pipelineの代替実装

[OpenSiv3DのWebブラウザ版をEmscriptenで作ろうとした話][1] にもある通り、[glCreateShaderProgramのリファレンスページ][3] には、`glCreateShaderProgram`の代替実装が紹介されています。

<details><summary>glCreateShaderProgramの代替実装</summary><div>

```cpp
const GLuint shader = glCreateShader(type);
if (shader) {
    glShaderSource(shader, count, strings, NULL);
    glCompileShader(shader);
    const GLuint program = glCreateProgram();
    if (program) {
        GLint compiled = GL_FALSE;
        glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
        glProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE);
        if (compiled) {
            glAttachShader(program, shader);
            glLinkProgram(program);
            glDetachShader(program, shader);
        }
        /* append-shader-info-log-to-program-info-log */
    }
    glDeleteShader(shader);
    return program;
} else {
    return 0;
}
```

</div></details>

しかしながら、WebGL2では`glCreateShaderProgram`の実装どころか、`glProgramParameteri`の実装自体が存在しません[^no-program-parameteri]。すなわち、頂点シェーダとフラグメントシェーダを別々のシェーダプログラムにリンクしてからアタッチするという方法自体がとれません。

[^no-program-parameteri]: Khronos Group, "WebGL 2.0 Specification", https://www.khronos.org/registry/webgl/specs/latest/2.0/#5.5, 2020/12/ 7 閲覧

そのため、描画に使用しようとしている頂点シェーダとフラグメントシェーダのハンドルを都度保存し、はじめて描画が必要となったときに、ペアとなっている頂点シェーダとフラグメントシェーダでシェーダプログラムのリンクを行い、そのシェーダプログラムをキャッシュするように実装を変更しました。

<details><summary>Program Pipelineの代替実装</summary><div>

```cpp:CRenderer2D_GL.hpp
class ShaderPipeline
{
public:
    enum class ProgramState
    {
        Created,
        Cached,
        Invalid
    };
private:
    HashTable<Vec2, GLuint> m_programs;
    GLuint m_currentProgram = 0;
    GLuint m_currentVS = 0;
    GLuint m_currentPS = 0;

    GLuint linkShaders()
    {
        if (m_currentVS == 0 || m_currentPS == 0)
        {
            return 0;
        }

        GLuint program = ::glCreateProgram();

        ::glAttachShader(program, m_currentVS);
        ::glAttachShader(program, m_currentPS);

        ::glLinkProgram(program);

        ::glDetachShader(program, m_currentVS);
        ::glDetachShader(program, m_currentPS);

        return program;
    }

    ProgramState setCurrentProgram()
    {
        Vec2 shaderSet { m_currentVS, m_currentPS };

        if (m_programs.contains(shaderSet))
        {
            m_currentProgram = m_programs[shaderSet];
            return ProgramState::Cached;
        } 
        else 
        {
            m_currentProgram = linkShaders();

            if (m_currentProgram != 0)
            {
                m_programs.emplace(shaderSet, m_currentProgram);
                return ProgramState::Created;
            }
            else
            {
                return ProgramState::Invalid;
            }
        }
    }
public:	
    ~ShaderPipeline()
    {
        for (auto [shaderSet, program] : m_programs)
        {
            ::glDeleteProgram(program);
        }
    }
		
    void setVS(GLuint vsProgramHandle)
    {	
        m_currentVS = vsProgramHandle;
    }
		
    void setPS(GLuint psProgramHandle)
    {
        m_currentPS = psProgramHandle;
    }
		
    ProgramState use()
    {
        auto programState = setCurrentProgram();
        ::glUseProgram(m_currentProgram);

        return programState;
    }
};
```

</div></details>

## glDrawElementsBaseVertexの代替実装

`glDrawElementsBaseVertex`自体はOpenGL ES3.2でも利用可能です[^gldrawelements-basevertex]。しかし、WebGL2はOpenGL ES3.0互換、残念ながらWebGL2には`glDrawElementsBaseVertex`は実装されていません。

とはいっても、`glDrawElementsBaseVertex`と`glDrawElements`の違いは、頂点インデックスの取り扱い方を指定できるかどうかの違いしかありません。OpenSiv3D Web版では、適宜頂点バッファへの書き込み位置をリセットすることで対応しました。

[^gldrawelements-basevertex]: Khronos Group, "glDrawElementsBaseVertex", https://www.khronos.org/registry/OpenGL-Refpages/es3/html/glDrawElementsBaseVertex.xhtml, 2020/12/ 7 閲覧

# 並列なコードの並行化

マルチスレッド自体はJavaScriptのWorkerが存在するので、マルチスレッドへの対応はできなくはありません。しかし、2018年からSpectre対応のために各ブラウザでSharedArrayBufferが無効化され[^spectre-countermeasure]、低コストでスレッド間のメモリ共有をすることができない状況が続いています。

[^spectre-countermeasure]: MDN Web Docs, "SharedArrayBuffer", <https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer>, 2020/12/ 7 閲覧

一方、JavaScriptではゲームループを単純なwhileとSleepの無限ループで実装することはできず、setTimeoutまたはrequestAnimationFrameを代わりに使うことなどして、適宜ブラウザに処理を戻す必要があります[^no-infinite-loop]。

[^no-infinite-loop]: RYOSKATE, "OpenSiv3DのWebブラウザ版をEmscriptenで作ろうとした話", <https://qiita.com/RYOSKATE/items/8644e711bf331d978551#メインループ>, 2020/12/ 7 閲覧

OpenSiv3D Web版ではこのメインループの実装を前提にすることに加えて、ワーカースレッドでもこの無限ループ中のSleepの代わりにsetTimeoutを使うようにしました。ちょうどOSがシングルコアのCPUでもタスクスケジューリングを行なってマルチプロセスを実現するように、JavaScriptでもsetTimeoutを使った自主的なタスクスケジューリングを行なって、擬似的なマルチスレッドという並列(にみせかけた並行)処理をさせてます。

<details><summary>スレッドの代替実装 (Audio実装部分)</summary><div>

```cpp:CAudio.hpp

class SimpleVoice_AL
{
private:

    // ~~ 中略 ~~

    bool onUpdate()
    {	
        if (!m_source)
        {
            return true;
        }
			
        if (m_isActive)
        {
            feed();
				
            ALint sampleOffset = 0;
            ::alGetSourcei(m_source, AL_SAMPLE_OFFSET, &sampleOffset);
        }
			
        ALint currentState = 0;
        ::alGetSourcei(m_source, AL_SOURCE_STATE, &currentState);
			
        if (!m_isEnd && m_isActive && currentState == AL_STOPPED)
        {
            onStreamEnd();
        }
			
        if (m_abort)
        {
            return false;
        }
			
        return true;
    }

    static void onUpdateHelper(void *userData) {
        if (static_cast<SimpleVoice_AL*>(userData)->onUpdate()) {
	    ::emscripten_set_timeout(&SimpleVoice_AL::onUpdateHelper, 10.0, userData);
        }
    }
```

</div></details>

# あとがき

SIMD命令の対応とデスクトップOpenGLからOpenGLESへの切り替えは、デスクトップアプリをWebGLに移植するときに限らず、デスクトップアプリをAndroid, iOS(OpenGLESは非推奨になってしまいましたが...)に移植するときにも有用ではないかと考えています。

この記事が、OpenSiv3D Android版やiOS版の開発が進むきっかけになればという他力本願で本稿を締めさせていただきます。最後まで読んでくださり、ありがとうございました。

[1]: https://qiita.com/RYOSKATE/items/8644e711bf331d978551
[2]: https://qiita.com/agehama_/items/8aafac87c17c7f94c7cb
[3]: https://www.khronos.org/opengl/wiki/GLAPI/glCreateShaderProgram