Transform Feedback の基礎
今回のサンプルの実行結果
GPGPU の強い味方
前回は、WebGL 2.0 から利用可能となった centroid
修飾子について扱いました。
アンチエイリアスの話なんかも出てきたりして、若干当サイトのテキストとしては高難度な話になってしまったような気もしますが、テクスチャの反対側の色が滲んで見えてしまうような現象を避けることができますし、しっかりと理解した上で、活用していきたい技術なのではないかなと思います。
さて今回ですが、WebGL 2.0 の新機能のなかでも、とりわけ注目度の高い Transform Feedback をやってみようと思います。この記事を書いている 2018 年 1 月現在、深層学習や機械学習に注目が集まり、行列演算などを高速に行うことができる WebGL はグラフィックス以外の用途についても注目が集まる機会が増えています。また、そういった用途においては、グラフィックス処理は不要なわけですから、なおのこと Transform Feedback のような演算処理に特化した技術は有用となります。
実はだいぶ前にサンプルそのものは作ってあったのですが、記事を執筆する時間がなくて、なかなか公開できずに「そのうち書きます」みたいなことをずっとやってきてしまっていたのですが……なんとかわかりやすく、この場で紹介できたらなと思います。
Transform Feedback とはそもそも何者なのか
さて、それではまず Transform Feedback とはなんなのか。その概要から見ていきましょう。
WebGL などのグラフィックス API は、ご存知の通り GPU を利用して高速に演算処理を行うことで CG を描画します。Transform Feedback は一言でいうと、そんな GPU の高速な演算能力を拝借しつつ、最終的な出力はグラフィックスではなくバッファへの書き戻しとして実行される、そんな機能です。
うーん、ちょっとわかりにくいですね……
これまでの WebGL のプログラミングでは、頂点に関するパラメータは、VBO(Vertex Buffer Object)というかたちでシェーダに送っていました。シェーダのなかでは attribute 変数としてそれらを参照することができ、それらの頂点属性を用いた行列による変換などを行うのが一般的なのでしたよね。
ここでのポイントは「シェーダ内で参照できる attribute 変数は、常に CPU から入力されたデータである」という点です。WebGL では、その実行環境である JavaScript によって最初の頂点データが作られます。配列を定義して、その配列のなかに、頂点座標や法線、頂点カラーなどを格納しておき、シェーダに送るわけです。つまりここでは常にCPU で生成されたデータを GPU に送るという流れで処理が進んでいくわけです。
しかし Transform Feedback を使うと、なんとこの処理の流れを変えることができます。
具体的には、GPU で計算した結果を再度頂点バッファに書き込むことができる ようになるのです。
WebGL 1.0 までは、シェーダに送られた頂点データは常に一方通行で必ずなにかしらのグラフィックスとして出力されていました。直接スクリーンに出なくても、たとえばフレームバッファに焼きこんだりする場合であっても、画面には出ないというだけで要するにグラフィックスが内部的には生成されていたわけです。しかし Transform Feedback を用いた処理では、グラフィックスではなく VBO の中身を GPU で更新する、ということができるようになります。
これにより、これまではテクスチャを介して行っていた GPGPU 処理が、直接 VBO の中身を更新してしまうというより直接的な方法で実現できるようになります。
Transform Feedback ではラスタライズを行わないように指示することが可能ですので、浮動小数点テクスチャなどを利用した GPGPU 処理よりも、より効率的に GPGPU が行えるようになるんですね。
VBO の usage について
Transform Feedback がどのような機能なのか、なんとなくイメージできるようになったでしょうか。
ここからはより具体的にその実装方法について確認していきましょう。
まず前提として、Transform Feedback は先述の通り、VBO の中身を GPU で直接書き換えていきます。しかし、テクスチャなどがそうであるように、Transform Feedback を利用する場合でも「現在バインドしているバッファから読み出しと書き込みを同時に行うことはできない」ということをまず念頭に置きます。テクスチャも、バインドして読み出しを行いながら、同時にそこにレンダリングを行うことはできません。同様に、Transform Feedback を利用する際も、読み出し用と書き込み用の、ふたつの VBO を同時に用意する必要がありますので注意しましょう。
その前提に立って考えると、まずは「読み出し用の VBO」と「書き込み用の VBO」を用意する必要がある、ということがわかりますね。
このことをまずしっかり意識しておくと、このあとの処理の流れを追いかける際に混乱しにくいと思います。
また、VBO を生成する際に利用する gl.bufferData
というメソッドには、第三引数に usage という項目があります。この usage には、通常だと gl.STATIC_DRAW
を指定する場合が多いと思います。この gl.STATIC_DRAW
は、アプリケーションが一度だけデータを設定し、あとは WebGL によってレンダリングに繰り返し利用されるということを示すために使われます。
Transform Feedback を利用する際の VBO の usage は、いったいどのように設定するのが正解なのか……正直なところ、これについては絶対的な正解というのは無いようです。というのは、usage とはあくまでも「こういうふうに使う予定です!」ということを指標として宣言するためのもので、これが機能を制限したり限定したりするパラメータとしてみなされるわけではないからです。ですから極端な話、アプリケーション(つまり JavaScript)から何度もアクセスするにもかかわらず STATIC_DRAW を使っていたからといって、それでプログラムが正しく動作しなくなるとは限らないわけですね。
一応、私が調べた限りの最適解ということで言えば、今回のサンプルの場合は Transform Feedback を使った後に JavaScript 側でその値を読み出すことはしていません。ですから、実際 CPU 側で VBO の値を指定するのは一度きり、ということになります。Transform Feedback によって、WebGL 側(GPU 側)では値が何度も更新されることになるのですが、そのことを踏まえて今回のケースではひとまず gl.DYNAMIC_COPY
を使うようにしました。ちなみに DYNAMIC_COPY は、WebGL 側(GPU 側)によって設定された値を何度も使う、というような意味になります。
VBO を複数生成する
それではちょっと前置きが長くなってしまったのですが、Transform Feedback のための VBO 生成について実際のコードを見ながら考えてみましょう。
まず、一番最初に attribute 変数としてシェーダに送られる初期データ、これを VBO に詰め込む部分を考えましょう。
Transform Feedback を使うとしても、最初の入力値というのはなにかしら用意しなくてはなりません。もちろん、そのデータの全てをゼロ埋めしただけの形にしてもいいのですが、今回は、初期値として頂点の本来の座標とカラーがシェーダに送られるようにしてみます。
ここでのポイントは、初期値の GLSL への入力用の VBO と、Transform Feedback の書き戻し用の VBO を、ここで両方共に準備している、ということです。
頂点の本来の情報を配列に格納する
// vertices
var position = [];
var color = [];
var feedbackPosition = [];
var feedbackColor = [];
(function(){
var i, j, k, l;
var x, y;
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;
// 頂点の本来の位置を配列に格納
position.push(x, -y, 0.0, 1.0);
// 頂点の本来の色を配列に格納
color.push(
targetImageData.data[l] / 255,
targetImageData.data[l + 1] / 255,
targetImageData.data[l + 2] / 255,
targetImageData.data[l + 3] / 255
);
// 空の VBO を作るためのゼロ埋めを行う
feedbackPosition.push(0.0, 0.0, 0.0, 0.0);
feedbackColor.push(0.0, 0.0, 0.0, 0.0);
}
}
})();
var transformOutVBO = [
create_vbo(position),
create_vbo(color)
];
var feedbackInVBO = [
create_vbo_feedback(feedbackPosition),
create_vbo_feedback(feedbackColor)
];
この部分だけを見るとちょっと混乱があるかもしれませんが、上記のコードの中にある targetImageData
は、別途 canvas2d を使ってビットマップから画素の色を取り出した結果の ImageData です。つまり、画像を canvas2d で別の canvas に一度焼き、その ImageData を事前に取り出しておいたものを用いて、頂点にあらかじめ色の情報を持たせるために使っている、という感じですね。
また、引用したコードの冒頭部分で、頂点座標用と、Transform Feedback の書き戻し用に、ふたつずつ配列を宣言しているところにも注目です。
先程も書いたように、Transform Feedback を用いるとしても、まずはバッファにゼロ埋めでもなんでもデータを格納するための領域を作っておく必要があります。ここでは、わかりやすくするためにこのように配列をふたつずつ用意し、そこにゼロ埋めするような処理を置いてあります。
念のために補足すると、VBO を生成するための gl.bufferData
の第二引数には、Float32Array.BYTES_PER_ELEMENT
などを使ってバイト長を数値で指定することも可能です。ただ、今回のサンプルでは、わかりやすさを重視する意味でまったく同じ長さの配列を用意するようにしています。position
という名前の変数が「初期値としての頂点座標用」で、feedbackPosition
という名前の変数が「Transform Feedback によって書き戻される領域として使われる VBO 用」の配列になっています。頂点カラーのほうも、同じ要領ですね。
そして上記のコードをよーく見るとわかると思うのですが、VBO を生成するための関数が実は二種類用意されているのに気がついたでしょうか。
最終的に配列にデータを詰め込んだあとに呼ばれている create_vbo
と、create_vbo_feedback
のふたつです。
これは、先程も触れましたが、Transform Feedback によって書き込みされる VBO には、usage に異なる設定をしたいわけですね。それで、ほんのわずかな違いではあるのですが、用途をわかりやすくするために、関数を二種類用意して使い分けるようにしています。
VBO を生成する二種類の関数を用意
function create_vbo(data){
var vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
return vbo;
}
function create_vbo_feedback(data){
var vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_COPY);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
return vbo;
}
ご覧の通り、両者の違いは gl.bufferData
の第三引数の usage の部分だけです。
GLSL と Transform Feedback
さて VBO の生成部分は終わりましたが……
脅すつもりはありませんが、Transform Feedback の難解な部分は実はここからが本番です。
落ち着いて、焦らず理解していきましょう。
まず最初に、Transform Feedback はシェーダ内で演算した結果を VBO に書き戻す機能ですので、当然ですがシェーダが影響を受けます。つまり、シェーダとの連携の都合も考えながら準備を行ってやる必要があります。
これらのことをスムーズに理解するためにも、今回のサンプルの構造をしっかり先に理解しておきましょう。
今回のサンプルでは、シェーダをふたつ用意しています。ひとつ目のシェーダでは、VBO に対して書き込みを行います。そして、ふたつ目のシェーダでその VBO を attribute 変数として参照することを目指します。
- ひとつ目のシェーダで書き込みする
- ふたつ目のシェーダで読み込みする
ここはぜひ落ち着いて考えてみてほしいのですが、ふたつ目のシェーダについては、これまでやってきたことと何も変わっている部分はありません。純粋に VBO から頂点属性を読み出して使うだけですから、従来と同じことをやっているだけです。問題になるのはひとつ目のシェーダの方ですね。こちらは、VBO に書き込むことをしなければならないわけですから、ここで Transform Feedack が出てくるわけです。
ではそれらのことを踏まえて、以下のコードを見てみましょう。
Transform Feedback とシェーダの初期化
// transform feedback object
var transformFeedback = gl.createTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);
// out variable names
var outVaryings = ['gl_Position', 'vColor'];
// transform out shader
var vs = create_shader('vs_transformOut');
var fs = create_shader('fs_transformOut');
var prg = create_program_tf_separate(vs, fs, outVaryings);
最初に gl.createTransformFeedback
というメソッドが呼ばれています。これはもうメソッド名を見れば明白ですが、ここで Transform Feedback オブジェクトを生成しています。さらに次の行では gl.bindTransformFeedback
を使って生成した Transform Feedback オブジェクトをバインドしています。
バッファ類やテクスチャなどと同じように、Transform Feedback もオブジェクトを生成してバインドする、という手順が必要なのですね。
そして Transform Feedback オブジェクトをバインドした状態で、次に行うのがシェーダ内で使われる変数と Transform Feedback オブジェクトとの関連付けです。変数 outVaryings
に配列で、文字列の組を代入していますよね。これが、その後登場する create_program_tf_separate
に引数として渡されています。該当の関数のなかでどのような処理になっているのか、見てみましょう。
プログラムオブジェクトの初期化を専用のものに変更
function create_program_tf_separate(vs, fs, varyings){
var program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.transformFeedbackVaryings(program, varyings, gl.SEPARATE_ATTRIBS);
gl.linkProgram(program);
if(gl.getProgramParameter(program, gl.LINK_STATUS)){
gl.useProgram(program);
return program;
}else{
alert(gl.getProgramInfoLog(program));
}
}
ここで注目してほしいのは、プログラムオブジェクトにコンパイル済みのシェーダをアタッチした直後の部分、メソッドで言うと gl.transformFeedbackVaryings
が呼ばれている部分です。
先程の引用部分で、文字列の配列を作っていましたが、あの配列はここで使われています。後述しますが、このときの配列の中身の順序は重要なので、正しく変数名が並んでいるようにしなければいけません。さらにこのメソッドは、プログラムオブジェクトをリンクする前に実行する必要がありますので、その点にも十分に注意しましょう。第一引数がプログラムオブジェクト、第二引数が文字列による配列、そして第三引数がバッファをどのように扱うかの指定です。
第三引数には、ここでは gl.SEPARATE_ATTRIBS
が指定されていますが gl.INTERLEAVED_ATTRIBS
を指定することもできます。これは書き戻す VBO を別々にしているのかそれともインターリーブドにしているのかによって使い分ければいいでしょう。インターリーブドってなんですかっていう人は、以下の記事を参考にしてみてください。
さてここまでを簡単にまとめると、Transform Feedback ではシェーダの挙動が変化するわけですから、当然のようにシェーダに関連した処理の流れが変わります。
具体的には、プログラムオブジェクトを生成した際に、リンクを行う前の段階で Transform Feedback に利用する 書き戻し用の変数名 を指定してやる必要があるわけですね。これらは文字列の配列として準備しておき、それを gl.transformFeedbackVaryings
に与えることで実現できる、というわけです。
今回のサンプルではシェーダがふたつ用意されており、最初のひとつ目のシェーダだけが、Transform Feedback を利用します。ですからふたつ目のシェーダは、これまでと同じ方法で初期化すれば大丈夫。あくまでも、Transform Feedback を利用するシェーダに関してのみ、ここで説明した手順での初期化が必要になりますので、気をつけましょう。
Transform Feedback の開始と終了
さて、Transform Feedback を実現するために、まずは VBO を正しく準備してやり、さらにシェーダのリンク時にも注意を払う必要がありました。やることが多くて大変ですが、これでひとまず準備は完了です。
いよいよレンダリングの部分を見ていこうと思いますが、ここでもまた、新しいメソッドが出てきます。
まず、Transform Feedback には「有効と無効のふたつの状態がある」ということを意識します。Transform Feedback オブジェクトをバインドしたかどうかではなく、あくまでもバインドしてあることは前提の上で、アクティブ化されているか非アクティブな状態か、ふたつの状態があるということです。
この、アクティブな状態を開始するには、以下のようにします。
Transform Feedback のアクティブ化
// Transform Feedback を有効化(アクティブ化)
gl.beginTransformFeedback(gl.POINTS);
もうなんていうか、名前がそのまんまですね……
この gl.beginTransformFeedback
が呼ばれると、その時点でバインドされている Transform Feedback オブジェクトが有効になります。ちょっと気になるのは引数ですよね。ここで指定されている gl.POINTS
は、Transform Feedback がアクティブ化されている間に利用できるプリミティブタイプの指定です。上記のような感じで Transform Feedback を開始したときは、それを明示的に終了させるまでは gl.POINTS
でしか描画が行えなくなるというふうに考えるといいでしょう。
なんとなく想像付くと思いますが、Transform Feedback を非アクティブな状態にするには、以下のようにします。こちらは、引数は必要ありません。
Transform Feedback の非アクティブ化
// Transform Feedback を無効化(非アクティブ化)
gl.endTransformFeedback();
これは簡単ですね。単に gl.endTransformFeedback
を呼ぶだけです。
要するに Transform Feedback には開始と終了を明示してやらなければならない、というルールがあることが理解できれば OK です。
描画プロセスは複雑だが落ち着いて考えよう
さて長かった解説もあと少しです。
ここまで見てきた内容を踏まえて、実際のレンダリングの様子を確認していきます。
処理の流れとしては、まず最初に VBO 類を正しくバインドする、というところからスタートします。次に Transform Feedback を有効化し、実際に描画を行います。最後に、ちゃんと Transform Feedback に関する後始末をして終わりになります。
実際のコードを見ながら考えてみましょう。
レンダリングループの中を抜粋
// program (第一のシェーダ)
gl.useProgram(prg);
// VBO のバインド
set_attribute(transformOutVBO, attLocation, attStride);
// 書き込み先の VBO をバインド
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, feedbackInVBO[0]);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 1, feedbackInVBO[1]);
// begin transform feedback
gl.enable(gl.RASTERIZER_DISCARD);
gl.beginTransformFeedback(gl.POINTS);
// 描画命令
gl.uniform1f(uniLocation[0], nowTime);
gl.uniform2fv(uniLocation[1], mousePosition);
gl.drawArrays(gl.POINTS, 0, imageWidth * imageHeight);
// end transform feedback
gl.disable(gl.RASTERIZER_DISCARD);
gl.endTransformFeedback();
// 書き込み先の VBO をバインド解除
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 1, null);
さて、ここでもまた! 新しいメソッドが出てきています。
書き込みに利用する VBO をバインドするときは、専用のメソッドを用います。それが gl.bindBufferBase
です。第一引数には gl.TRANSFORM_FEEDBACK_BUFFER
を定形で指定します。そして第二引数には 0 や 1 といった整数を指定しなくてはいけないのですが、これが、先述した 書き戻し用変数の名前の配列 の順序と一致していなくてはいけません。
先程掲載した該当箇所を一部抜粋すると、以下のようになっていましたよね。
書き戻し用変数の名前を配列で指定している部分
// out variable names
var outVaryings = ['gl_Position', 'vColor'];
ここで配列に格納した際のインデックス(添字)と、先程の gl.bindBufferBase
の第二引数のインデックスを一致させるわけですね。この順番を間違えると書き込み先のバッファが入れ違ってしまったりするので、十分注意しましょう。
また、その下にもなにやら初めて見るものがあると思います。
深度テストなどを有効化する際に利用する gl.enable
メソッドに、初めて見る引数として gl.RASTERIZER_DISCARD
が指定されているのがわかるでしょうか。これは引数の名前のとおり、ラスタライズ処理を無効化させるために使うフラグです。文章にするとめっちゃややこしいのですが「ラスタライズを Discard する」ことを enable しているので、これでラスタライズが結果的には無効化される形になります。
この、ラスタライズの無効化ができるという点が、テクスチャを用いた GPGPU よりも効率的になるのでは? と予想させる部分ですよね。実際にはベンチマーク取ってみなければ確実なことは言えないのですが、普通に考えると、ラスタライズをスキップしてバッファに値を直接書き戻せるわけですから、テクスチャを使った GPGPU 処理などと比較すると Transform Feedback のほうが高速に処理できそうな気はします。
一応念のために書いておくと、これらの Transform Feedback 用の設定やバインド処理は、Transform Feedback が必要無くなった時点でしっかりと元に戻しておきましょう。特に、ラスタライズを無効化したままにしておくと、普通に考えれば画面になにも映らなくなるということになってしまいますので、要注意です。
まとめ
さて、WebGL のご多分に漏れず、ハッキリ言ってやることがすごく増える印象のある Transform Feedback ですが、いかがでしたでしょうか。
Transform Feedback を有効化して正しく行った描画処理では、演算結果はスクリーンやテクスチャではなく、VBO に直接書き戻しされます。今回のサンプルでは、第一のシェーダを使って VBO に対して値を書き込み、それを第二のシェーダで読み込んだ上で頂点を波打たせています。
少しもったいぶった言い方をすると、今回のサンプルのように毎フレーム第一のシェーダで波打つような頂点座標を作ることには実用的な意味は無いです。今回のサンプルはあくまでも Transform Feedback を理解してもらうためのわかりやすさ重視のものであり、このような処理の流れを推奨しているわけではありません。
ではどういう処理が実用的なのか、ということを考えてみると、それはやはり GPGPU ということになると思います。
今回は書き込み役とそれを読み込む役とが、別々でした。
しかし GPGPU 的な処理を行う上で最も多いユースケースとしては、GPU 内部でひたすら繰り返し演算を行っていくことになります。シェーダで計算した結果を VBO に書き戻し、次のフレームではその書き戻した値を現在値として再度シェーダに入力、さらに演算した新しい結果を書き出し……というようにループさせるわけですね。
今回のサンプルはわかりやすさ重視で作りましたが、次回あたりは、そういった GPGPU らしいサンプルをやってみましょうかね。
まあとにかく覚えることが多いので、最初は焦らずじっくり取り組んでみるのがいいと思います。
実際に動作するサンプルは以下のリンクから。