GPGPU でパーティクルを大量に描く

実行結果

今回のサンプルの実行結果

GPU の力を引き出すのだ!

前回は VBO を逐次更新しながら処理することで、CPU 側で頂点の座標を計算してパーティクルを動かす、というテクニックを解説しました。

このやり方の場合、頂点の計算が CPU の性能に依存する形になるので、WebGL 特有の GPU パワーを引き出すことができるというメリットはそれほど活かされない一方、javascript 側ですべて計算しているのでそれほど WebGL や GLSL の記述に精通していなくても、処理を記述しやすいというメリットがあったのでしたね。

そんな中で今回は、これを GPU 側に持っていきます。いわゆる GPGPU と呼ばれる類の技術です。

前回は、あくまでも頂点の座標計算について javascript で行いましたが、今回はこれを GPU 側、つまりシェーダにやってもらいます。つまり、頂点の座標計算から描画までの一連の処理が、基本的に GPU 側で動くことになるわけです。

単純計算に非常に強い GPU の特性を存分に利用できるので、今回はパーティクルの数が前回より大幅に増えています。前回はせいぜい 1 万程度でしたが、今回はさくっと 26 万頂点いってみましょう。GPU って、すごいですね。

VTF と GPGPU

GPGPU という言葉は、なんとなく見てわかるとおり、略称です。正確には「General-purpose computing on graphics processing units」というらしいです。

この言葉の意味としては、本来はグラフィックス処理に利用される GPU を、それ以外の用途に使ってしまおう、という意味になりますね。つまり、映像出力とは関係のない、頂点の座標計算を GPU にやらせる今回のサンプルは、まさに GPGPU を行っていると言えますね。

しかし、WebGL には正式に GPGPU という枠組みが備わっているわけではありません。API の仕様上は、GPGPU といった言葉がつくメソッドなどは用意されていないわけです。しかし、テクスチャをうまく利用することで、この問題は解決できます。

より具体的には、今回のサンプルを実現するためには以下に示すような技術を組み合わせて使います。

  • VTF(vertex texture fetch)
  • 浮動小数点数テクスチャ

いずれも、既に当サイトのテキストで解説しているテクニックですが、これらを組みわせることで超高速な GPU の演算能力を引き出します。もし、上記のふたつが何を指しているのかがわからないようなら、事前に過去のテキストを読んでおくことをおすすめします。

いかにして実現するか

VTF を用いることができれば、頂点シェーダ内でテクスチャを参照し、その値を読み出すことが可能です。

つまり、VBO として頂点の座標を頂点シェーダに(attribute 変数として)送らなくても、テクスチャを参照して座標を取り出すことができれば、それを元に頂点を描画することが可能というわけです。普通はテクスチャに書き込まれるのは色であることが多いですが、今回の場合は色としての意味の値ではなく、計算した頂点の座標をテクスチャに焼きこみ、それを頂点シェーダで参照して利用するわけですね。

同時に、テクスチャにも工夫を凝らします。

通常のテクスチャではなく、拡張機能を有効化させることで利用が可能となる浮動小数点数テクスチャを使います。

ここからはちょっとしたおさらいになりますが、通常、テクスチャを生成する際には、以下のように記述することが多いです。

テクスチャにイメージデータを割り当てる処理

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);

全部で 6 つある引数のうち、第五引数にここでは注目します。通常、テクスチャを利用する際には gl.UNSIGNED_BYTE を用います。アンサインド、つまり、符号なしです。ということは、通常のテクスチャには負の数値は格納できないことになります。

その点、浮動小数点数テクスチャの場合は、どのように記述するのかと言うと先ほどの第五引数には gl.FLOAT を指定します。アンサインドが付加されていないことからもわかるとおりで、この場合は負の数値も扱うことができる精度の高いテクスチャとして利用することができます。

この浮動小数点数テクスチャを用いれば、頂点の座標を計算した後、それをテクスチャに格納しても負の数値も含めた高精度の値として保存できますね。これを、VTF で読み出しながら参照すれば、前回は javascript で行っていた頂点の座標計算を、GPU 側で行うことが可能になります。

当然、javascript ではなく GLSL でベクトルの計算などを行うことになりますが、これはむしろ GLSL のほうが書きやすいくらいです。むしろ注意すべきは、VTF が利用できないハードウェアが存在する可能性があること、そして拡張機能である浮動小数点数テクスチャにも、同様のことが言える点です。また、浮動小数点数テクスチャは拡張機能なので、正しい手順で有効化してやらないと利用できません。そのあたりは、過去のテキストをしっかり読んで理解しておいてください。

三組のシェーダを使いこなせ

VTF + GPGPU パーティクルを実現するために、今回はシェーダを 3 つ使っています。

座標の計算や、頂点のレンダリングは個別にシェーダを用意してやり役割ごとに内容を切り分けます。

  • 頂点座標の初期位置を格納するシェーダ
  • 頂点座標を計算し更新するシェーダ
  • テクスチャを参照し頂点を描画するシェーダ

今回のサンプルも、前回同様に頂点の初期位置は画面いっぱいに敷き詰められた状態になるようにしています。この、敷き詰められた状態の初期位置を決めるために、最初に一度だけ第一のシェーダが走るようにします。

第一のシェーダは、板ポリゴンを一枚、スクリーン全体を覆う形で描画させます。その際、フラグメントシェーダで gl_FragCoord を取得しつつ頂点の初期位置を決めて値をテクスチャに書き込みます。

頂点の初期位置を書き込む

precision mediump float;
uniform vec2 resolution;
void main(){
	vec2 p = (gl_FragCoord.xy / resolution) * 2.0 - 1.0;
	gl_FragColor = vec4(p, 0.0, 0.0);
}

ここでは javascript からフレームバッファの解像度(今回の場合 512px 四方です)を受け取り、それを利用して正規化して座標の初期値を決めています。-1 から 1 までの範囲に正規化して敷き詰めることで、画面いっぱいに頂点が散りばめられた状態を作っています。

このシェーダが走ると、フレームバッファにアタッチされたテクスチャの各テクセルに、正規化された頂点の初期座標が書き込まれます。今回はフレームバッファのサイズを 512px 四方にしているので、単純計算で 262,144 個分の頂点座標をこのフレームバッファひとつで保持できるわけです。

先述の通り、このシェーダはあくまでも初期位置を書き込むためのものなので、アニメーションループが始まる前に一度だけドローコールを呼んでやります。この時にその描画の対象になるのはフレームバッファです。本番の canvas に書き込むわけではないので、注意しましょう。

また、フレームバッファは今回ふたつ、用意します。

これはアニメーションループが始まったあと、前回の計算結果を再度参照しながら処理を行う必要があるからです。複数のフレームをまたぐようなスコープの変数は GLSL では記述できないので、フレームバッファを二組用意して、それぞれをフリップしながら処理することで、前回の計算結果を次のフレームで参照できるようにするのですね。

頂点座標位置の更新

さて、第二のシェーダでは、テクスチャから読みだした座標の位置と、マウスカーソルの動きとを考慮して、頂点情報を更新します。今回のサンプルは前回同様、ドラッグ操作が行われている間のみ、パーティクルがカーソルの位置へと向かうようなベクトル演算を実装します。

シェーダに送るマウスカーソルの位置は、canvas の真ん中を原点とした -1 から 1 までの範囲の正規化済みの値を渡すようにしています。

ベクトル演算で頂点情報を更新する

precision mediump float;
uniform vec2 resolution;   // フレームバッファの解像度
uniform sampler2D texture; // 前フレームの座標が格納されたテクスチャ
uniform vec2 mouse;        // マウスカーソル座標(正規化済み)
uniform bool mouseFlag;    // マウスボタンが押されているかのフラグ
uniform float velocity;    // 加速度係数(初期値は 0.0)
const float SPEED = 0.05;  // パーティクルの速度係数
void main(){
	vec2 p = gl_FragCoord.xy / resolution;  // テクスチャ座標を計算
	vec4 t = texture2D(texture, p);         // 前フレームの座標読み出し
	vec2 v = normalize(mouse - t.xy) * 0.2; // カーソル位置へのベクトル
	vec2 w = normalize(v + t.zw);           // ハーフベクトルで向きを補正

	// テクスチャから読みだした値(vec4)は……
	// XY が頂点の座標を、ZW で頂点の進行方向ベクトルを表している
	vec4 destColor = vec4(t.xy + w * SPEED * velocity, w);

	// ドラッグされてない場合は前回の進行方向を維持する
	if(!mouseFlag){destColor.zw = t.zw;}
	gl_FragColor = destColor;
}

ちょっとわかりにくいかなと思ったので、コード側にコメントを大量に追記しました。

まず大前提として、今回のサンプルではテクスチャの RGBA にどのような値を格納しているのか、という点をしっかり押さえておきましょう。

テクスチャの各テクセルに書き込めるのは、浮動小数点数テクスチャであっても vec4 ひとつ分までです。今回の場合、パーティクルは二次元で処理するので、XY にはそのまま頂点の座標を入れます。しかし、そうすると ZW が使われていないのでもったいないですよね。そこで、その ZW には、頂点の進行方向を表すベクトルを格納するようにしています。

ここがイメージできないと、シェーダのコードを読み解くのが難しいと思いますので、落ち着いて考えてみてください。

パーティクルがカーソル位置めがけてホーミングのような追尾する動きをするのは、前回とまったく同じロジックです。ハーフベクトルをうまく利用して、微妙にカーソルの方向に進行方向を修正するような動きにしています。

第三のシェーダでいよいよ頂点を描く

さて、第二のシェーダが走ったところまででは、あくまでもテクスチャ上で頂点の座標情報や進行方向ベクトルを操作しているだけでした。実際にスクリーン上に頂点を描き出すのは、第三のシェーダの役割です。

第三のシェーダでは、attribute 変数としてちょっと面白いものが利用されます。もしかすると、シェーダのコードを見ただけでは、これの中身がどんな情報なのか、一見してわからないかもしれません。

頂点をレンダリングするシェーダ

// 頂点シェーダ
attribute float index;
uniform vec2 resolution;
uniform sampler2D texture;
uniform float pointScale;
void main(){
	vec2 p = vec2(
		mod(index, resolution.x) / resolution.x,
		floor(index / resolution.x) / resolution.y
	);
	vec4 t = texture2D(texture, p);
	gl_Position  = vec4(t.xy, 0.0, 1.0);
	gl_PointSize = 0.1 + pointScale;
}

// フラグメントシェーダ
precision mediump float;
uniform vec4 ambient;
void main(){
	gl_FragColor = ambient;
}

頂点シェーダを見ると index という attribute 変数がひとつだけ宣言されています。

今回の趣旨は GPGPU なので、当然の如く position などの見慣れた attribute 変数はありません。頂点の座標は、VBO で準備するのではなくテクスチャから読み出すわけです。それでは、この index という謎の attribute 変数の正体とは一体なんなのでしょうか。

その後の処理の流れを追っていくと、変数 p に値を格納する部分で、問題の index という変数が使われているのがわかりますね。ここでは、参照すべきテクスチャ座標を算出する処理が行われています。

attribute 変数 index には、実は頂点が何個目のものなのかを表す連番が入っています。今回の場合はフレームバッファのサイズが 512px 四方であることから、総テクセル数が 262,144 あります。このテクセルひとつひとつが頂点の座標と進行方向ベクトルをそれぞれ格納しているのでしたね。そして、attribute 変数 index には、参照すべきテクセルを特定するための連番が 0 から 262,143 までの範囲で入っているわけです。

そのことを踏まえて、再度テクスチャ座標を算出する処理の部分を見てみましょう。

該当部分を抜粋

vec2 p = vec2(
	mod(index, resolution.x) / resolution.x,
	floor(index / resolution.x) / resolution.y
);

除算の剰余を計算する mod と、除算の整数部分だけを抽出する floor を利用して、連番の番号から、どのテクセルを参照すればいいのかを算出しているのですね。頂点の連番は左下から右に向かってインクリメントされるようになっています。

javascript 側の実装

三組のシェーダは、それぞれ異なる役割を持っていますが、それらを呼び出す順番などは、javascript 側で記述を行う際に十分注意するようにしましょう。頭のなかが混乱しやすいので、落ち着いて考えるのがいいと思います。

まず第一のシェーダは、レンダリングのアニメーションループが始まる直前に、一度だけ呼んでおきます。ここでは、頂点の初期位置を書き込むのが目的なわけですから、当然レンダリング対象はフレームバッファです。

実際にレンダリングがループするようになったら、まず最初は、第一のシェーダで初期位置を書き込んだフレームバッファのテクスチャを、シェーダ内で参照する必要がありますよね。ですから、フレームバッファのテクスチャを、まずはバインドします。

次に、第二のシェーダを呼び出して、フレームバッファ(2)に更新した頂点情報を書き込みます。フレームバッファ(1)と、フレームバッファ(2)と、ふたつのフレームバッファは相互に前回のフレームの座標位置を保持しあう関係になることがポイントです。アニメーションループの最後の方には、このふたつのフレームバッファをフリップ(交換)する処理が記述してあります。

フレームバッファを置き換える

// フレームバッファをフリップ
flip = backBuffer;
backBuffer = frontBuffer;
frontBuffer = flip;

一度、別の変数を経由させてフリップさせているのがわかると思います。つまり、今現在のフレームで書き込まれた頂点の座標情報は、次のループではバックバッファとなり、前回の座標として参照されるようになるわけですね。

第三のシェーダは頂点を実際に canvas 上にレンダリングするのが役割なので、このときはフレームバッファは一切バインドされていない状態になります。

テクスチャや、フレームバッファなど、バインドとその解除の関係がわかりにくいので、焦らず落ち着いてコードを読み解いていきましょう。

まとめ

さて、ちょっと文章が中心のテキストだったので若干味気ない感じもしますが、GPGPU パーティクルの実装、いかがでしたでしょうか。

正直なところ、言っていることはなんとなくわかるけどイマイチよくわからん! という人が多いのではと思います。GPGPU はあまり WebGL 界隈では見かけない処理なので、概念の理解や頭のなかでイメージするのが、ちょっと難しいですね。

ただ、今回のサンプルは実に 26 万個もの頂点を一斉に動かしているわけですが、それを考えたらかなり高速に動作するということが感じられると思います。前回の VBO を javascript で更新する方法と比較して、もちろんハードにより差があるとは思いますが 20 倍程度はパフォーマンスがアップしていることになります。使いドコロは難しいですが、効果的に利用できればかなり強力なアプリケーションを設計することができると思います。

まずはサンプルを動かして遊んでみましょう。そこから少しでも興味をもったのなら、諦めずに少しずつで構いませんから、コードを読み解いたり、変数や計算式の値を修正するなどしながら研究してみてください。

今回のサンプルは以下のリンクから参照できます。

entry

PR

press Z key