Transform Feedback で GPGPU

実行結果

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

GPU のチカラで高速演算

前回は、transform feedback を利用するための基本的な設定項目や、考え方について扱いました。

どうしても、準備のために行う処理の量が多いので、頭の中がすごく混乱しやすい概念だと思いますので、落ち着いて考えるようにしてみてください。

さて今回は、前回のサンプルを少し改造して transform feedbackGPGPU を実装してみます。

実は当サイトには、過去に GPGPU を扱ったサンプルが既にあります。(参考: GPGPU でパーティクルを大量に描く) ただ、このサンプルは WebGL 1.0 をベースにしたもので、transform feedback ではなく浮動小数点テクスチャを使って、テクスチャベースで GPGPU を行っている例です。

今回はテクスチャを介すること無く、直接 transform feedback で VBO を更新し、GPGPU を行います。

基本は前回と同じ

さて、実際にどのように実装するかですが、基本的なプログラムの構成や必要な設定項目は、実は前回とほぼ同じと考えていいと思います。

ですからこのテキストでは、新しい API などは一切出てきません。

ただ、概念としてはちょっと違いが紛らわしいというかややこしいので、一度しっかり確認しておきましょう。

まず、前回のサンプルでは VBO を transform feedback で更新する際、あくまでもわかりやすさを重視した内容にしていたので、初期状態の「読み込み用 VBO」と、計算結果を書き込む「書き込み用の VBO」を分けていました。つまり読み込み用の VBO の内容は従来の WebGL の実装と同様に中身が変更されることはなく、あくまでも transform feedback によって変化するのは書き込み用の VBO のみでした。

今回は、この構成を変えます。

具体的には「読み込み用と書き込み用を兼用する VBO」を最初からふたつ用意します。そして、1フレーム目で書き込み用に使った VBO を次の2フレーム目では読み込み用に使う、といったようにスワップする方式です。

このような実装にすることで、A を読み込み B を更新、次のフレームでは B を読み込み A を更新、と次々に VBO の計算結果を引き継ぎながら計算を継続して行うことができるようになります。テクスチャを介するのではなく、直接 VBO の中身をシェーダだけでガリガリ更新していくわけですね。

全体的な構成や設定はほぼ同じなのですが、VBO が前回より少し増えたような感じになります。実際に、VBO を生成する際には、初期状態の値を書き込んだ VBO を作るのですが、今回のサンプルではまったく同じ初期値を格納した VBO がふたつ用意されるようになっています。

VBO を初期化する部分の処理

// vertices
var position = []; // 頂点の座標
var velocity = []; // 頂点の進行方向
var color = [];    // 頂点の色
(function(){
    var i, j, k, l, m;
    var x, y, vx, vy;
    for(i = 0; i < imageHeight; ++i){
        y = i / imageHeight * 2.0 - 1.0;
        k = i * imageWidth;
        for(j = 0; j < imageWidth; ++j){
            x = j / imageWidth * 2.0 - 1.0;
            l = (k + j) * 4;

            // 頂点座標は -1.0 ~ 1.0
            position.push(x, -y, 0.0);

            // 進行方向は正規化したベクトル
            m = Math.sqrt(x * x + y * y);
            velocity.push(x / m, -y / m, 0.0);

            // 頂点カラーは読み込んだ画像由来
            color.push(
                targetImageData.data[l]     / 255,
                targetImageData.data[l + 1] / 255,
                targetImageData.data[l + 2] / 255,
                targetImageData.data[l + 3] / 255
            );
        }
    }
})();

// まったく同じデータからなるふたつの VBO を配列に入れておく
var VBOArray = [
    [
        create_vbo(position),
        create_vbo(velocity),
        create_vbo(color)
    ], [
        create_vbo(position),
        create_vbo(velocity),
        create_vbo(color)
    ]
];

今回は VBO で更新した結果を使って、パーティクルを一気に動かすような実装になっています。

パーティクル(頂点)が存在する位置を保持するための position と、それがどちらに向かって進んでいるのかを示す velocity、さらに色を持っているという状態ですね。

フレームごとにスワップするように交互に書き込むことになるので、実際には、ふたつ目の VBO は中身が全部ゼロとかでも問題はないのですが、一応初期化時にまったく同じ値が格納されるようにしてあります。

毎フレームごとに VBO を切り替える

さて、VBO が初期化できたら、あとは前回のサンプルを改造して、それぞれが交互に状態を更新し合うような仕組みを作ってあげればいいだけです。

毎フレーム処理される VBO が交互に入れ替わるような仕組みはとてもシンプルに作ることができます。ポイントは、変数名で言うと VBOArray という名前になっている配列の使い方です。

今回のサンプルでは、レンダリングが開始されると、毎フレーム必ずインクリメントされる変数を用意しています。変数名で言うと count という名前の変数です。これを、毎フレームインクリメントすると、それを 2 で割った余りが、0 か 1 のいずれかになることがわかります。この、割った余りの部分を配列のインデックスとして使えばいいわけです。

該当する部分を一部抜粋して見てみます。

除算の剰余を使ったインデックスの取得

var count = 0; // 初期値は 0 を入れておく
render();

function render(){

    (中略)

    // increment
    ++count;
    var countIndex = count % 2;
    var invertIndex = 1 - countIndex;

変数 count は毎フレームインクリメントされるため、それを 2 で割った余りは常に 0 か 1 になります。これが変数 countIndex に格納されます。また、その数値を 1 から減算すると、やっぱりその結果も 0 か 1 になりますね。これが invertIndex です。

両者は、必ず一方が 0 ならもう一方は 1 という値になりますので、これを使って「読み込み用 VBO」と「書き込み用 VBO」が交互に切り替わるようにしてやります。

バインドする VBO をインデックスで割り振る

// set vbo
set_attribute(VBOArray[countIndex], attLocation, attStride);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, VBOArray[invertIndex][0]);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 1, VBOArray[invertIndex][1]);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 2, VBOArray[invertIndex][2]);

set_attribute 関数で VBO をバインドするので、つまりこれが「読み込み用の VBO」ですね。そして transform feedback によって更新される「書き込み用 VBO」を設定しているのは gl.bindBufferBase です。

これで、毎フレーム VBO の役割は相互に入れ替わるようになりましたので、あとは描画の際に、VBO が更新されるように処理してやり、更新された VBO を最終的なシーンを描画するシェーダへと渡してやります。

ちょっと長いですが、レンダリングする際の流れを確認するために、該当箇所のコードを見てみましょう。コメントをよく見て、どのような流れで処理が行われているのかを掴むように心がけるといいと思います。

render 関数の中身(抜粋)

// increment
++count;
var countIndex = count % 2;
var invertIndex = 1 - countIndex;

// transform feedback で VBO を更新するシェーダ
gl.useProgram(prg);

// 読み込み用 VBO をバインドし、書き込み用を設定する
set_attribute(VBOArray[countIndex], attLocation, attStride);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, VBOArray[invertIndex][0]);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 1, VBOArray[invertIndex][1]);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 2, VBOArray[invertIndex][2]);

// transform feedback の開始を設定
gl.enable(gl.RASTERIZER_DISCARD);
gl.beginTransformFeedback(gl.POINTS);

// uniform 変数などを設定して描画処理を行い VBO に書き込む
gl.uniform1f(uniLocation[0], nowTime);
gl.uniform2fv(uniLocation[1], mousePosition);
gl.uniform1f(uniLocation[2], mouseMovePower);
gl.drawArrays(gl.POINTS, 0, imageWidth * imageHeight);

// transform feedback の終了と設定
gl.disable(gl.RASTERIZER_DISCARD);
gl.endTransformFeedback();
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 1, null);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 2, null);

// 最終的に画面に出る絵をレンダリングする
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clearDepth(1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.viewport(0, 0, canvasWidth, canvasHeight);

// プログラムオブジェクトを切り替え
gl.useProgram(fPrg);

// 先程更新したほうの VBO を描画するためにバインド
set_attribute(VBOArray[invertIndex], fAttLocation, fAttStride);

// canvas 上にレンダリング
gl.uniformMatrix4fv(fUniLocation[0], false, vpMatrix);
gl.uniform1f(fUniLocation[1], mouseMovePower);
gl.drawArrays(gl.POINTS, 0, imageWidth * imageHeight);

gl.flush();

行数がどうしても多くなってしまうので面食らってしまうかもしれませんが、ひとつひとつ順番になにをやっているのかを追いかけていけば、それほど複雑な構造にはなっていないと思います。

テクスチャを使った GPGPU よりも、むしろシンプルになっているのではないかなと個人的には思うのですが、どうでしょう。

一応シェーダのほうも確認

今回のサンプルは動かしてみるとわかるかと思いますが、暗い三次元空間の中を、カラフルなパーティクルがドバーっと動きます。

やっていることは単純で、進行方向をマウスカーソルの位置などに依存して変化させている感じです。

ベクトルを足し込む量を制限することで、一度に進行方向を変化させられる量を制限しつつ、正規化して VBO に書き出しています。座標は、その進行方向を使って移動させるだけなので、もっと単純です。

VBO 更新用の頂点シェーダ

#version 300 es
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 velocity;
layout (location = 2) in vec4 color;

uniform float time;
uniform vec2 mouse; // -1.0 ~ 1.0
uniform float move; // 0.0 ~ 1.0

out vec3 vPosition;
out vec3 vVelocity;
out vec4 vColor;

void main(){
    vPosition = position + velocity * 0.1 * move;
    vec3 p = vec3(mouse, sin(time) * 0.25) - position;
    vVelocity = normalize(velocity + p * 0.2 * move);
    vColor = color;
}

恐らくちょっとわかりにくいのが、uniform 変数の move ですかね……

ここには、HTML 側でマウスボタンが押されている場合は 1.0 の値が、ボタンが押されていない場合はその値が減衰していき 0.0 まで減算されるようになっています。もし仮にこの変数が 0.0 だったら、座標が更新されることがないのがわかるのではないでしょうか。

また、パーティクルの進行方向については、マウスカーソルの位置と、サイン波で変化するような形になっています。

三次元空間上をグリグリ動くパーティクルも、Z 方向に進行方向が変化しないとあまり奥行き感が感じられず味気ない雰囲気になってしまうので、時間の経過でパーティクルが進もうとする目標地点が、微妙に前後にずれるような感じにしてあります。

まとめ

さて、かいつまんで解説する感じでしたが、前回の記事の内容をしっかりと把握できてさえいればそれほど難しくない内容だったのではないかなと個人的には思います。

テクスチャを使った従来の GPGPU の場合、書き込みたい情報が増えれば増えるほど、フレームバッファを量産していかないといけないのが大きな問題でした。一方で transform feedback を利用する方法では、単に attribute 変数を増やせばいいだけなので、拡張が非常に簡単になりますね。

GLSL で直接頂点の座標を更新したり、あるいはパラメータを変化させたりすることができ、しかも頂点シェーダのみで処理するため非常に高速です。transform feedback が WebGL 2.0 でしか使えないことを除けば、利用しない理由があまり無く、あえてテクスチャベースで GPGPU を行う必要性はほとんど無いと言ってもいいと思います。

最初の実装を作るところまでがなんとも大変なのですが、一度作ってしまえば、使い回しもしやすいのではないかなと思います。ぜひがんばってトライしてみてください。

実際に動作するサンプルは以下のリンクから。

entry

PR

press Z key