LoginSignup
7
6

More than 3 years have passed since last update.

【JavaScript】WebGL2 ComputeShaderを使ってGPGPU計算

Last updated at Posted at 2019-10-09

GPGPUという単語や意味はぼんやり理解していたつもりですが、どのような実装や開発環境があるのかは全く知りませんでした。

しかしJavaScriptのWebGL経由でブラウザから今すぐ試せると知って、理解を深める為にもやってみるかとなったのがきっかけで、調べた事をまとめました。

謎の言語

WebGLについて調べていくうちに、JavaScriptとはかけ離れた謎の言語が登場して早速詰みました。ぴえん🥺

GLSL

その正体は、GPUのシェーダーコアを制御するためのシェーディング言語 "GLSL" というものでした。

エントリポイントがvoid main()だったり、最初に変数宣言を済ませる必要があったりとC言語ベースですが、拡張が多く存在します。

GLSLの仲間たち

なお "GLSL" はOpenGL向けですが、他にもDirect3D向けの "HLSL" やNVIDIA系列向けの "Cg" など、各プラットフォーム向けにいくつかのシェーディング言語が存在します。

WebGLとOpenGLとANGLE

WebGLは、OpenGLのモバイル向けサブセットであるOpenGL ESを使用しています。

バージョン

初代WebGLはOpenGL ES 2.0で、モダンブラウザで利用可能なWebGL2はOpenGL ES 3.0です。
なおGoogle Chromeでは、試験的にWebGL2が拡張されてOpenGL ES 3.1にも対応しています。

ちなみに、初代WebGLは驚くべき事に何と、あのInternetExplorerにも対応しています。

コンピュートシェーダーへの対応

今までは頂点シェーダーとピクセルシェーダーしか使用できませんでしたが、上述のWebGL2拡張によりChrome試験機能としてコンピュートシェーダーが使用できるようになりました。

ANGLE

ChromeにおけるWebGL実装は "ANGLE" と呼ばれる、OpenGL ESをフルバージョンのOpenGLやDirect3DやVulkanに変換するAPIへ渡ってから実行されます。

そして、コンピュートシェーダーはDirect3Dでいうと11の機能、フルバージョンOpenGLでいうと4.3の機能なので、使用にあたってはOS側でそれぞれ対応している必要があります。

OpenGLのコンピュートシェーダー

コンピュートシェーダーは他のシェーダーと何が違うのか、簡単にまとめてみます。

シェーダーの歴史

その昔、GPGPUなんていう言葉が生まれる前から存在していた頂点シェーダーとピクセルシェーダーは、当然ですが描画するために作られたので "数値入力→座標計算→色計算→結果出力" という一連の流れ(パイプライン)以上の機能は不要でした。

それが徐々に「GPUって単純な数値計算ならCPUでやるより爆速じゃね?」となり、描画以外の汎用的な計算処理も行われるようになりました。

しかし、汎用計算となると処理を描画パイプラインに当てはめる必要があり、データのやり取りにもっと自由度が欲しくなります。

そこで、従来の描画パイプラインから切り離され汎用計算に特化したコンピュートシェーダーが登場しました。

データのやり取りが自由に

コンピュートシェーダーには、シェーダー側のバッファとアプリケーション側のバッファをバインドしてデータを自由にやり取りできる "SSBO" という機能が追加されました。

SSBOは、アプリケーション側だけでなくシェーダー内部の別の領域ともバインドできます。

このように、コンピュートシェーダーは汎用計算を前提としており、そもそも描画を目的として作られた頂点シェーダーやピクセルシェーダーとは全く異なる役割を担っているシェーダーというわけです。

必要環境

  • Google Chrome 77以上
  • DirectX 11 (Windowsの場合)
  • OpenGL 4.3 (Linuxの場合)

PCが各種グラフィックAPIに対応しているかは、Chromeページから確認できます。

Chromeでコンピュートシェーダーを試用するには、試験機能フラグを有効化する必要があります。

これらの通り、WebGL2のコンピュートシェーダー機能はかなり初期段階の実装で、実行可能な環境はそれなりに限られてくると思いますので、その点はご留意ください。

HTMLサンプル

gpgpu.html
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="height=device-height, width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">

        <title>GPGPU</title>
    </head>

    <body>
    </body>

    <script id="shader" type="x-shader/x-compute">
        #version 310 es

        layout (local_size_x = 1024, local_size_y = 1, local_size_z = 1) in;

        layout (std430, binding = 0) buffer SSBO {
            float data[];
        } ssbo;

        void main(){
            int ssboLength = int(ssbo.data.length());

            int wgSizeX = int(gl_WorkGroupSize.x);
            int wgCountX = int(gl_NumWorkGroups.x);
            int gIdX = int(gl_GlobalInvocationID.x);

            int offset = ssboLength;
            if(ssboLength > wgSizeX){
                offset = ssboLength / wgCountX / wgSizeX;
            }

            for(int i = 0; i < offset; i++){
                int index = gIdX * offset + i;
                ssbo.data[index] = ssbo.data[index] * 2.0;
            }
        }
    </script>

    <script>
        class GPGPU{
            #ctx = new OffscreenCanvas(1, 1).getContext("webgl2-compute");

            constructor(src){
                const shader = this.#ctx.createShader(this.#ctx.COMPUTE_SHADER);
                const program = this.#ctx.createProgram();

                this.#ctx.shaderSource(shader, src);
                this.#ctx.compileShader(shader);

                if(!this.#ctx.getShaderParameter(shader, this.#ctx.COMPILE_STATUS)){
                    throw new Error(this.#ctx.getShaderInfoLog(shader));
                }

                this.#ctx.attachShader(program, shader);
                this.#ctx.linkProgram(program);

                if(!this.#ctx.getProgramParameter(program, this.#ctx.LINK_STATUS)){
                    throw new Error(this.#ctx.getProgramInfoLog(program));
                }

                this.#ctx.useProgram(program);

                this.#ctx.deleteShader(shader);
                this.#ctx.deleteProgram(program);
            }

            compute(data, x, y, z){
                if(!(data instanceof Float32Array)){
                    throw new Error("Allowed type is only Float32Array");
                }

                const result = new Float32Array(data.length);

                const ssbo = this.#ctx.createBuffer();

                this.#ctx.bindBuffer(this.#ctx.SHADER_STORAGE_BUFFER, ssbo);
                this.#ctx.bufferData(this.#ctx.SHADER_STORAGE_BUFFER, data, this.#ctx.DYNAMIC_COPY);
                this.#ctx.bindBuffer(this.#ctx.SHADER_STORAGE_BUFFER, null);

                this.#ctx.bindBufferBase(this.#ctx.SHADER_STORAGE_BUFFER, 0, ssbo);

                this.#ctx.dispatchCompute(x, y, z);

                this.#ctx.getBufferSubData(this.#ctx.SHADER_STORAGE_BUFFER, 0, result);

                this.#ctx.deleteBuffer(ssbo);

                return result;
            }
        }

        // サンプルデータを用意
        const sample = new Float32Array(0x01000000).map(() => Math.random());

        // GPUインスタンス
        const gpgpu = new GPGPU(document.getElementById("shader").textContent.trim());

        // GPUで実行タイム計測
        performance.mark("start_gpu");
        gpgpu.compute(sample);
        performance.mark("end_gpu");

        // CPUで実行タイム計測
        performance.mark("start_cpu");
        sample.map(n => n * 2.0);
        performance.mark("end_cpu");

        performance.measure("exec_gpu", "start_gpu", "end_gpu");
        performance.measure("exec_cpu", "start_cpu", "end_cpu");
        console.log(`GPU: ${performance.getEntriesByName("exec_gpu").pop().duration} ms`);
        console.log(`CPU: ${performance.getEntriesByName("exec_cpu").pop().duration} ms`);
    </script>
</html>

この例では、16,777,216要素にMath.random()が入った配列を各要素毎に2倍する処理の実行時間を計測しています。

GLSL側とJavaScript側との処理を追っていきます。

GLSL

GLSLソースコードは<script>内に書き込めます。

MIMEをJavaScript以外(例ではx-shader/x-compute)とかにしておけば、ブラウザ側で不要な解析をされずに済みます。

なお、GLSLのお作法として.a .b .c.x .y .zなど、最初のアルファベット1文字を起点として、そこから続いてn番目というプロパティ指定方法があります。

💡version

これもGLSLのお作法で、1行目は必ずOpenGLバーション指定で始まる必要があります。

今回はコンピュートシェーダーを使用するので310 esとします。

なお、文字列として読み込むとインデントも改行や空白の扱いとなってしまうのでtextContentでロードする時はtrim()で取ってあげると良いでしょう。

💡layout

色々な大きさだったり配置だったりを指定する構文です。

- layout(1個目)

local_size_x,y,zで各次元ごとにワークグループ(スレッドの纏まり)の大きさを指定します。
だいたい128くらいが妥当らしいです(適当)

- layout(2個目)

SSBOを定義します。
std430というのがバッファ領域のメモリレイアウトの規格らしく、他にもstd140などがありました。
bindingで何番目のバインドかを指定します。

その下の括弧内にバインドする配列を指定します。
SSBOは可変長配列を1つだけ持てますが、可変長配列の宣言は必ず固定長配列を全て宣言した後でなければいけません。

NG
{
    float data0[3];
    float data1[];
    float data2[8];
}
OK
{
    float data0[10];
    float data1[5];
    float data2[];
}

💡gl_GlobalInvocationID

割り当てられたスレッド番号のようなものです。
今回は1次元なので.xで整数のスレッド番号を取得できます。

💡gl_WorkGroupSize

ワークグループの大きさです。
すなわちlocal_size_x,y,zで指定した値となります。

💡gl_NumWorkGroups

現在実行されているワークグループ数です。
JavaScript側のdispatchCompute()(後述)で発行したワークグループの数となります。
これも、各次元.x .y .zでそれぞれの値を取得できます。

💡forループ

基本的にはよくあるfor文なのですが、カウンタ変数はint(符号付整数型)で宣言する必要があります。
最初書いた時、カウンタ変数をuint(符合無整数型)で宣言したらエラーでした。

JavaScript

WebGLに関する部分は、見慣れないメソッドや定数が多く出現しますが、基本的にはお作法を踏襲しながら記述していくので、難しい事をしているわけではありません。

💡Canvas

普通のcreateElement()でも生成できるのですが、今回はせっかくなので新しいCanvasを使ってみました。

- OffscreenCanvas

DOMから切り離されディスプレイ表示が出来なくなった代わりに、WebWorkerで扱えるようになったCanvasオブジェクトです。

従来のcreateElement()で生成するCanvasオブジェクトはDOMなのでWebWorkerでは扱えませんでした。

ディスプレイ表示以外は、DOM有り版と同一に扱えます。

OffscreenCanvasの利点としては、アニメーションやゲームなど描画オブジェクトが大量な場合、Workerで予め各描画を済ませておき、必要になったらメインスレッドへ描画済データを転送する事で、メインスレッドは表示だけで済むのでレスポンス低下を避けられる、といったところです。

今回は、特に何かを描画させるわけではなくシェーダーを使うためのコンテキストが欲しいだけなので、コンストラクタ引数(width, height)はそれぞれ1で問題ありません。

- getContext()

webgl2-computeを指定する事で、コンピュートシェーダーのレンダリングコンテキストを取得できます。

💡WebGL(初期化)

基本的に処理結果をreturnで返すのでは無く、引数として受け取ったオブジェクトに直接変更を加えます。

1: createShader(), createProgram()

シェーダーオブジェクトとプログラムオブジェクトを初期化生成します。

2: shaderSource()

GLSLソースコードをシェーダーオブジェクトに追加します。

3: compileShader()

シェーダーオブジェクトに追加されたソースコードを、実行可能データにコンパイルします。

4: attachShader()

コンパイルされたシェーダーを、プログラムオブジェクトに追加します。
この段階ではプログラムオブジェクトにただ追加されただけで、プログラムから実行される事はありません。

5: linkProgram()

プログラムオブジェクトに追加されたシェーダーを、実行するシェーダーとしてリンクさせます。
プログラムオブジェクトが実際にシェーダーを実行するためには、この段階を踏む必要があります。

6: useProgram()

レンダリングコンテキストにプログラムを適用します。

7: deleteShader(), deleteProgram()

一度レンダリングコンテキストに適用したシェーダーオブジェクトやプログラムオブジェクトはもう使わないので、明示的に削除します。

💡WebGL(計算開始)

定数SHADER_STORAGE_BUFFERは "このバッファはSSBOである" ということを指しています。

1: createBuffer()

バッファオブジェクトを初期化生成します。

2: bindBuffer()

バッファオブジェクトをバインドします。

3: bufferData()

バッファにデータ配列を書き込みます。
DYNAMIC_COPYは、シェーダー側とアプリケーション側どちらからでも読み書き出来ることを示しています。
なお、データを書き込んだ後は再びbindBuffer()nullを指定し、バインドを明示的に解除しておきます。

配列型は、GLSLソースコードのlayout SSBOで指定した型と合わせる必要があります。

4: bindBufferBase()

GLSLソースコードのlayout SSBOのバインド番号binding = nにバッファオブジェクトをバインドします。

5: dispatchCompute()

各次元のワークグループ数を指定して計算実行します。

例えば、以下の例の場合

layout (local_size_x = 128) in;
JavaScript
dispatchCompute(2, 1, 1);

x次元は1ワークグループあたり128スレッドで、2ワークグループ分を実行するので256スレッドが実行される、といった感じです。

6: getBufferSubData()

GLSLソースコードのlayout SSBOのバインド番号binding = nのバッファデータを、引数で渡した配列の長さ分だけ取得

計算する

上記gpgpu.htmlを実行した時の処理時間を計測しました。

計測は以下の環境で行いました。

  • CORE2Quad Q9550
  • DDR2-1066 4GB
  • Windows7 32bit
  • Google Chrome 83

部屋に転がってたPCなので、マシンスペックについては目を瞑ってもらいましょう...

結果

  • CPU: 431 ms
  • GPU: 231 ms

約半分の時間で計算できました。

私のGitHub Pagesで試せます。

適材適所

なお、計算するデータが少ない場合は、都度バッファを読み書きする手間などでオーバーヘッドが重なり、トータルで見るとCPUの方が高速になります。

使い分けが大事ですね。

おわりに

WebGLという単語は見たことあっても、全く関わりのない分野だと思っていました、今までは。

でもこうして実際に、書いてみて、動いて、そして圧倒的な速さを見せつけられると、何かワクワクしてしまいます😁

GLSLについては存在自体を知って数日なので、まだまだこれから感はありますが、地道に色々試してみようと思います。

では最後はこれで。

「あ、ありのまま今起こったことを話すぜ!おれはJavaScriptを書いていたはずが、いつの間にか全く別の言語を書いていた...頭がどうにかなりそうだった...TypeScriptだとか、Nodeだとか、そんなチャチなものじゃあ断じてねぇ...もっと恐ろしいものの片鱗を味わったぜ...」

おわり(?)

Special Thanks!

7
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6