WebGL2ではTransformFeedbackという機能が使えるようになりました。TransformFeedbackがあればGPUでの計算結果をバッファに書き出すことができるようになります。これを使えば、なかなか面白いことができるようになります。

この記事では、TransformFeedbackを利用したGPGPUと、パーティクル描画について解説します。

前提知識

この記事では、WebGL2について、基礎的な知識を持っていることを前提としています。まだWebGL2に慣れ親しんでいない人は、以下の記事を参考にしてください。

また、WebGL1/WebGL2について、@h_doxas様がwgld.orgという解説サイトを開いています。そちらも参考にしてください。

ソースコード

この記事で使われているプログラムの完全なコードはGithubに置いてあります。必要な方は以下のリンクからどうぞ:

WebGL2によるGPGPU

GPGPUってなんだろう

GPGPUという言葉を聞いたことがあるでしょうか。少し前に流行った言葉ですが、最近はあまり聞かないかもしれません。廃れたというわけではなく、単に極めて一般的な技術になったため、あまり明言する必要がなくなったのです。

GPGPUというのは、General-Purpose computing on GPU(GPUによる汎用計算)の意味で、GPUにいろいろな計算をやらせてみようという趣旨の言葉です。GPUというのはPCのグラフィックボードやスマートフォンなどに搭載されている画像処理プロセッサのことです。

汎用的な計算をするために複雑な条件分岐などを扱うCPUに対して、GPUは画像処理に特化しているため、作りがとても単純です。作りがシンプルなおかげで大量のコアを搭載でき、CPUがせいぜい6コアや8コアとか言ってる横で、GPUは2500コアぐらい搭載しています。画像処理はピクセルごとに独立した計算をすればいいため、2500コアで並列処理をすればとても効率的に扱うことができます。

ここでGPUの性能に目をつけた人たちがいました。「この性能を画像処理だけに使うのは勿体無いのではないか?」「高性能な計算機として使えるのではないか?」と。これがGPGPUの始まりです。

GPUはベクトル計算や行列計算が得意です。そういった処理をGPUに任せてしまえば、効率的に計算をすることができます。一方でCPUほど高機能ではないので、条件分岐などは苦手です。CPUに任せる処理と、GPUに任せる処理、その見極めが大事になってきます。

WebGLとGPGPU

GPGPUの実現方法にはいくつかあります。WebGL1のような単純なAPIを使うときは、計算結果をテクスチャに書き出して、テクスチャの値を読み込むことでGPGPUを実現していました。しかしこれはだいぶ遠回りです。

WebGL2ではTransformFeedbackという機能が追加されました。これはGPUによる計算結果をバッファに書き出せるという機能です。TransformFeedbackを使えば、シンプルにGPGPUを実現することができます。

今までは、バッファ(入力)→バーテックスシェーダ→フラグメントシェーダ→canvas、と一方通行の処理の流れがありました。ここでTransfromFeedbackを使うと、バーテックスシェーダの出力を横取りして使うことができます。つまり、バッファ(入力)→バーテックスシェーダ→バッファ(出力)、のような使い方ができるようになります。

これを使えば、バーテックスシェーダを使って、GPGPUによる並列計算ができるようになります。

WebGL2によるGPGPU

例えば2つの四次元ベクトルを足すだけのプログラムを考えてみましょう。TransformFeedbackを使えばバーテックスシェーダの計算結果を横取りできるので、バーテックスシェーダで計算します。ソースは以下のようになります:

非常に単純ですね。あとはこいつをシェーダプログラムとしてコンパイルして利用してやればいいだけです。

ですがシェーダプログラムは対になるフラグメントシェーダが必要です。計算結果を横取りされて特に役目のないフラグメントシェーダですが、一応ダミーが必要になります。中身はなんでもいいので、適当に書きましょう:

このふたつのシェーダを読み込んでシェーダプログラムとしてコンパイルします。

あとはこの2つのシェーダをリンクするだけですが、ここでひとつ準備があります。プログラムをリンクする前に、どの変数を横取りするかを指定しなければいけません。横取りする変数はtransformFeedbackVaryingメソッドで指定できます。このメソッドには、シェーダプログラム、横取りする変数の名前の配列、書き出し方式、の3つが必要になります。以下のように書きます:

ここではout変数resultだけを横取りして、SEPARATE_ATTRIB方式で書き出すことを指定してます。SEPARATE_ATTRIBは、ひとつの変数につきひとつのバッファを使う方式で、一方INTERLEAVED_ATTRIBSは複数の変数をひとつのバッファにまとめて書き出す方式です。今回はひとつしか使わないのでSEPARATE_ATTRIBSを指定しています。

あとは普通にバッファにデータを転送します:

これはいいでしょう。

さて、ここからです。まずバーテックスシェーダでの計算結果を書き出すためのバッファを用意します。これは普通のバッファで大丈夫です。このバッファには、あらかじめ書き出すための領域を確保しておきます。

次にWebGLTransformFeedbackオブジェクトを作成します。これはcreateTransformFeedbackというメソッドで作ることができます。

これでバーテックスシェーダの結果をバッファに書き出す準備はだいたい整いました。あとはもう少し準備をして計算するだけです。

まず、計算の前にラスタライザを無効化します。これは計算だけをしたいので描画は無効化したいためです。したくないならしなくても大丈夫です。

そして先ほど作成したWebGLTransformFeedbackオブジェクトをTRANSFORM_FEEDBACKに、書き出し先のバッファをTRANSFORM_FEEDBACK_BUFFERにそれぞれバインドします。

バインドするときには、それぞれbindTransformFeedbackメソッドと、bindBufferBaseメソッドを使います。

あとは描画命令を出すだけです。描画命令の前にbeginTransformFeedbackというメソッドを呼び出し、終わったらendTransformFeedbackというメソッドを呼び出す必要があります。

描画命令は、今回はベクトルの足し算を1回だけ行いたいので、1点分だけ呼び出しています。これでTRANSFORM_FEEDBACK_BUFFERにバインドしたバッファに結果が書き出されます。あとはgetBufferSubDataを使って読み出せば結果が得られます。

これを実行してみます。

ちゃんとベクトルの足し算ができています!

この程度の計算ならCPUでやったほうが速いとは思いますが、もっと複雑なベクトル計算・行列計算が出てきた場合は、GPGPUを使ってみましょう。

行列計算

変数がvecではなくmatの場合、少し転送方法を考える必要があります。以下のようなバーテックスシェーダがあるとします:

このとき、matAとmatBにはそのまま値をセットすればいけそうですが、残念ながらそれはできません。WebGL2では基本的にはバッファへの値の転送はベクトルで行われるからです。

なので例えばmat2であればvec2が2個、mat4ならvec4が4個と考えて分けて送る必要があります。具体的には以下のようにします:

ここまでのコード

パーティクル

TransformFeedbackを使えば、ほぼGPUだけでパーティクル(粒子)アニメーションを描画することができます。ちょっと触ってみましょう。

シェーダの準備

まずはシェーダを作ってみましょう。パーティクルに必要そうなのは、現在位置、経過時間、発生点、速度、年齢、寿命、あたりでしょう。これらを全部バーテックスシェーダで計算します。以下のようにします:

このバーテックスシェーダを「particle_updater_vs.glsl」として保存しましょう。これの計算結果をバッファに書き出して、その書き出した値を使って描画をする、という流れになります。

そしてこのバーテックスシェーダの対になるダミーのフラグメントシェーダが必要になります。実際は使わないので内容はなんでもいいのですが、例えば以下のようにします:

これを「particle_updater_fs.glsl」として保存します。

そして今回はもうひとつシェーダプログラムを作成します。計算だけでなく、パーティクルを描画したいので、描画用のシェーダプログラムを用意します。

描画のために必要なのは位置情報だけです。なのでバーテックスシェーダは以下のようになります:

これを「particle_renderer_vs.glsl」として保存します。そして描画用のフラグメントシェーダも用意します。今度は本当に使うので真面目に書きます:

これを「particle_renderer_fs.glsl」として保存します。

これで計算用プログラムと、描画用プログラムができました。あとはこれをJavaScript側でシェーダプログラムとして構成するだけです。

JavaScriptの前準備

前準備として、いくつか関数を作っておきます。シェーダプログラムを作るcreateProgram関数と、VertexArrayObjecを作成するcreateVAO関数です。以下のように作ります:

createProgram関数はtransformFeedbackVaryingsする変数の一覧を受け取っておきます。また、今回は複数の変数を扱うので、バッファへの書き出し方式はINTERLEAVED_ATTRIBSにしておきます。

VertexArrayObjectはバッファの情報をまとめるためのオブジェクトだと思ってください。VAOを作ることでバッファが扱いやすくなります。

パーティクルプログラムを作っていく

まずは必要なパラメータの定義や、シェーダプログラムのコンパイルなどの準備をしましょう。プログラムは計算用と描画用の2つ作ります:

次にパーティクルの初期値として使う値を用意します:

今回は1000個のパーティクルを描画したいので、1000個分のデータを用意します。

次に必要なVAOを作ります。これは先ほど用意したcreateVAO関数で簡単に作れます。バッファは入力用バッファ(input)と書き出し用バッファ(output)の2つ用意します。VAOは入力用バッファに対して計算用と描画用、書き出し用バッファに対して計算用と描画用の、合計4つが必要になります。

あとはフィードバックを作成するのと、細かい準備です:

ここでバッファやVAOを配列にまとめたのは、後でするちょっとしたテクニックのためです。

最後にアニメーションループを作成します。

ラスタライザを無効化してフィードバック、フィードバックが終わったら再有効化、という流れはGPGPUのときと変わりません。

今回は、まず計算用のプログラムを有効化し、パーティクルの位置などを計算します。ループごとにマウスの位置(origin)と前回からの経過時間(eplasedTimeDelta)をセットしています。そして入力用のVAOをバインドし、バッファに計算結果をフィードバックします。これで計算が完了します。計算はPARTICLE_NUM個(1000個)同時に行なっており、これで各パーティクルの位置が更新されます。

次に描画用のプログラムを有効化します。先ほどの計算で出力結果が保存されたバッファをVAOでバインドし、普通に描画します。これで各パーティクルが実際に画面に描画されます。

さて、問題は次です。計算に使ったバッファと出力に使ったバッファを入れ替えています。これは「次のフレームでは今回の計算結果のバッファを入力として計算する」という処理をやりたいためです。出力結果のバッファを次のフレームで計算用に使うことにより、「今の値を使って次の値を計算する」ということが実現できます。

完成

ここまでのコード