頂点テクスチャフェッチ(VTF)
今回のサンプルの実行結果
頂点シェーダでテクスチャの参照
前回は浮動小数点数テクスチャを、WebGL の拡張機能によって利用可能とする方法を解説しました。
仕組みとしては利用できたとしても、ハードウェアの性能によっては開発側が意図した精度が得られるとは限らないこともあり、今後に期待の掛かる楽しみな機能でした。
さて、今回は拡張機能とは少し違いますが、前回解説した浮動小数点数テクスチャと組み合わせることによって、とてつもない効果を生み出すかもしれない頂点テクスチャフェッチ(vertex texture fetch)をやってみたいと思います。
頂点テクスチャフェッチは、その英語表記の頭文字を取って VTF などと呼ばれることもあります。近年のコンシューマゲームなどでも割と活躍している技術の一つで、その応用範囲はかなり広いと言っていいと思います。
頂点テクスチャフェッチとは、簡潔に言うなら[ 頂点シェーダ内でテクスチャを参照すること ]を指します。
今までの当サイトのあらゆるサンプルがそうであったように、テクスチャを参照するのは基本的にはフラグメントシェーダです。色の出力に関する部分を担っているフラグメントシェーダではなく、名前のとおり頂点を処理することが仕事の頂点シェーダからテクスチャを参照することに、いったいどんなメリットがあるのでしょう。
そのあたりも含めて、じっくりと見ていきましょう。
VTF のメリット
頂点テクスチャフェッチ、すなわち頂点シェーダによるテクスチャの参照にいったいどんな利便性があるのか、皆さんは想像がつくでしょうか。
前回のテキストでも触れたように、テクスチャには最低でも 8 bit 精度の値を、名目上は色として格納できるという性質があります。ここで「名目上は」と書いたのは、別に色を表す用途にしか使ってはいけませんなんて、どこにも書いてはいないということです。
3D プログラミングの世界では、多くのデータを CPU 側、つまりプログラム側と GPU 側とでやりとりする必要があります。もうお馴染になった uniform 系メソッドなどを使って GPU にデータを渡してやり、それを使ってシェーダが処理を行うのでしたね。しかし当然と言えば当然ですが、シェーダが受け取れる uniform 変数の数には限りがあります。そして同時にこれらはハードウェア依存であり、環境によって扱える uniform 変数の個数は大きく変わってきます。
また、ある程度優秀なハードウェアでも、大量のデータを扱うようなケース、特に当サイトではまだ解説していませんがスキニングなどの処理においては枠が足りなくなってしまう場合も十分にあり得ます。そして、これらの問題を解消できる一つの手段が、他ならぬ VTF なんですね。
uniform 変数の限界個数
シェーダが受け取れる uniform 変数の数は、2014 年 3 月現在では PC でもベクトル単位で 250 程度が現実的な範囲だと思います。もちろん、優れた性能を持つグラフィックボードなどを搭載していれば、1000 以上のベクトルを扱うことができるはず。ただしその逆も然りで、一昔前の PC やタブレット、スマートフォンなどでは確実にもっと少なくなるでしょう。
シェーダで受け取ることができるベクトルの数が 250 もあるのなら、一見するとそれで十分な気もしますね。でも 4 x 4 の行列は、ベクトルを複数含んだデータ構造です。vec4 が四つの要素を持つデータ構造だとすれば、mat4 は実に十六もの要素を持っています。行列をそのまま uniform としてシェーダに送るとすると、250 という数値はけして大きな数値ではないことが想像できるのではないでしょうか。
uniform 変数に関するパラメータのチェック方法
WebGL には、ハードウェアがどの程度の性能を持つのか事前に調査する方法が用意されています。
uniform 変数を始めとするさまざまなパラメータについてチェックできるので、ここで簡単に解説しておきます。具体的には getParameter
メソッドに、調査したい内容を表す定数を引数に渡して走らせます。
uniform 変数の上限は、頂点シェーダとフラグメントシェーダ、それぞれ別々に値が取得できます。あらかじめ WebGL コンテキストが初期化されている状態で gl.MAX_VERTEX_UNIFORM_VECTORS
を引数に gl.getParameter
メソッドを実行すれば、頂点シェーダの uniform 変数の上限個数が戻り値として取得できます。
同様に gl.MAX_FRAGMENT_UNIFORM_VECTORS
を引数にすることで、フラグメントシェーダの上限を取得することが可能です。
テクスチャは広大なデータ格納庫
すでに何度も書いたように、通常、テクスチャに格納できるデータは 8 bit 精度です。
8 bit とはすなわち 0 を含む 256 諧調の精度を持つデータです。1 を 255 で割ってみると、おおよそ 0.0039215... という数値になります。つまりテクスチャは、おおよそ 0.004 程度の最小精度でそれほど問題にならない場合であれば、十分にデータの格納先として使えるということになります。
また、前回解説した浮動小数点数テクスチャを用いれば、その精度はさらに高いものになります。
先ほど uniform 変数としてシェーダに送ることができるのは現実的には 250 ベクトル程度という話をしました。しかしテクスチャを用いれば 256 x 256 ピクセルのテクスチャでも 65536 もの領域を確保できます。ご存じのとおり、テクスチャの 1 ピクセルには RGBA の四つのベクトルを格納できる領域があります。むしろ、これだけデータを大量に格納できるようなスペースを、使わないほうがもったいないような気さえしてきますね。
ただし、いいことづくめに見えるテクスチャですが、やはりここでもハードウェアの壁が立ちはだかります。
先ほどコラム枠で紹介した getParameter
メソッドを利用すると、頂点シェーダからテクスチャを参照することができるかどうかを調べることができます。これはすなわち、ハードが対応していない場合には頂点テクスチャフェッチは使えないということでもあります。
頂点シェーダからテクスチャが参照できなければ、当然 VTF は使えません。まずはこれが可能かどうかをチェックしましょう。
頂点テクスチャフェッチが可能かどうか調べる
// 頂点テクスチャフェッチが利用可能かどうかチェック
var i = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS);
if(i > 0){
console.log('max_vertex_texture_imaeg_unit: ' + i);
}else{
alert('VTF not supported');
return;
}
上記のように getParameter
メソッドに gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS
を与えて呼び出します。すると、頂点シェーダで扱うことのできるテクスチャの最大ユニット数が取得できます。※余談ですが当然フラグメントシェーダも調べられます
これが仮に 0 だった場合には、残念ながら頂点テクスチャフェッチは使えないことになります。PC であれば、現行のものならまず間違いなく大丈夫なはずです。モバイル端末などでは、まだ厳しいかもしれません。※というよりむしろまず WebGL の実行そのものが難しいのが現実ですね
テクスチャを仲介したデータ共有
さて続いては、実際に VTF が利用できるとして、どうやってテクスチャに必要なデータを格納すればいいのかを考えていきましょう。
従来、当サイトのサンプルではスクリーン(あるいはビュー)全体に対してフラグメントシェーダを走らせる場合に、正射影で板ポリゴンを使ったレンダリングを行うことで対応してきました。ガウシアンブラーや、各種フィルタ系の処理がそれにあたりますね。
この方法を使えば、確かにスクリーン全体をくまなくフラグメントシェーダで処理することができます。ただ、今回は少し実用的な方法として、次のような仕様でサンプルを作ってみたいと思います。
- 球体モデルの頂点データを用意する
- 各頂点の座標位置をテクスチャに描き込む
- 別のシェーダからテクスチャに格納された座標を読み出す
- 読み出した座標を使って頂点位置を調整する
手順としては上記のような感じですね。
まず、当サイトオリジナルの WebGL 用ライブラリ minMatrixb.js に含まれている球体モデルを生成できる関数を利用して、球体モデルの頂点データを用意します。
この球体モデルの頂点データを、シェーダ(仮に A とします)を使ってテクスチャに描き込みます。
続いて、別のシェーダ B から、シェーダ A でテクスチャに描き込んだ頂点の情報を読み出します。ここで読み出した頂点座標の情報を使って、最終的なシーンはシェーダ B によってレンダリングされるようにするわけです。
イメージ的にはテクスチャを仲介役にして、頂点座標位置を異なるシェーダ間でやりとりするような感じになります。
モデルと頂点データの準備
minMatrixb.js の球体モデル生成関数は、球体の分割数を引数で指定することができます。当サイトのサンプルでは何度も登場していますが、一応呼び出し例を記載しておきます。以下のような感じで呼び出してやると球体モデルのデータをオブジェクトで返してきます。
球体モデルデータ生成関数の呼び出し
// 球体モデル
var sphereData = sphere(15, 15, 1.0);
第一引数と第二引数は、モデルの縦横の分割数です。第三引数は球体の半径ですね。
今回のような引数指定で sphere
関数の呼び出しを行うと、頂点の数がちょうど 256 個の球体モデルデータが生成されます。今回はこの頂点データをテクスチャに描き込んでみましょう。頂点の個数が 256 個ということは、これを描き込むテクスチャのサイズは 16 x 16 ピクセルあれば十分ですね。このサイズでフレームバッファをどこかのタイミングで初期化して準備しておきしょう。
今までのサンプルでは、WebGL の定石どおり続けて VBO を生成していました。今回はそれに先立ってあることをやっておきます。
頂点データの加工
// 位置座標
var position = sphereData.p;
// 頂点インデックス
var indices = new Array();
// 頂点の数分だけインデックスを格納
j = position.length / 3;
for(i = 0; i < j; i++){
indices.push(i);
}
// 頂点情報からVBO生成
var pos = create_vbo(position);
var idx = create_vbo(indices);
先ほどの sphere
関数が返してきたオブジェクトから頂点位置を抜き出し position
という変数に入れておきます。これは単純に一次元の配列で XYZ のデータが 256 個分連なった配列データです。ですから要素の数は 256 x 3 で、768 個あります。
続いて、コメントで頂点インデックスと書かれた部分でもう一つ配列を初期化しています。変数 indices
がそれですね。ここには、頂点のインデックスデータを入れます。ただし、今までよく使っていた IBO のための頂点インデックスとはちょっと違います。
その下にあるループ構造を見るとわかると思いますが、このインデックス配列には単純に 0 ~ 255 までの連番をただ順番に代入しておきます。IBO として利用する頂点インデックスは、ポリゴンを描くための頂点の結び順を表すために使っていましたよね。今回はポリゴン云々ではなく、単純に頂点の個数分の連番を配列に入れておきます。
ちょっと紛らわしいのですが、今回のサンプルで言うところの頂点インデックスは、ポリゴンを描くためのものというよりも、単純に頂点に割り当てられた名札のような役割をします。データベースなどでいうユニークな ID のようなものですね。個々の頂点を識別するための番号として純粋な頂点の個数分の連番を配列に突っ込んでおくわけです。
しかし、なぜそのようなことをするのか不思議に思うかもしれません。実はこの識別番号が、テクスチャに頂点の情報を描き込む際に必要になるのです。
頂点データをテクスチャに描き込む
さてそれではどうして先ほど頂点の識別番号を用意したのか、その謎を解くためにシェーダのコードを見てみましょう。このシェーダは頂点の情報をテクスチャに格納するための役割を担います。
データ格納用シェーダ
attribute vec3 position;
attribute float index;
varying vec3 vColor;
const float frag = 1.0 / 16.0;
const float texShift = 0.5 * frag;
void main(void){
vColor = (normalize(position) + 1.0) * 0.5 ;
float pu = fract(index * frag) * 2.0 - 1.0;
float pv = floor(index * frag) * frag * 2.0 - 1.0;
gl_Position = vec4(pu + texShift, pv + texShift, 0.0, 1.0);
gl_PointSize = 1.0;
}
先ほど準備した頂点データと同じように、attribute 変数として頂点位置とインデックスを受け取るようになっているのがわかると思います。
そして、二つの float
型の定数を宣言していますね。定数 frag
には、描き込みの対象となるフレームバッファ(テクスチャ)の一辺あたりの長さで 1 を割ったものを入れておきます。そして定数 texShift
にはさらにその半分の大きさの値を仕込んでおきます。
この二つの定数は、テクスチャに値を描き込む際に非常に重要な役割を果たしてくれます。そのことを踏まえつつ main
関数の中身を見てみましょう。
ポイントとなるのは float
型の変数 pu
と pv
に値を代入している部分です。ここで登場する fract
というビルトイン関数は小数点以下の数値だけを抽出する機能を持っています。頂点の識別番号に定数 frag
を掛け、その結果の小数点以下の部分だけを抜き出すわけですね。さらにそこに 2 を掛けてから 1 を引いています。この計算が何を意味するのかわかるでしょうか。
理解を簡単にするために、頂点の個数が 4 個だった場合を考えてみましょう。頂点の個数が 4 個ということは、テクスチャのサイズは 2 x 2 の非常に小さなものになります。
頂点が 4 つの場合
この状態で、各頂点に割り当てた識別番号ごとに、上の図のマス目の部分に対して描き込みを行いたいとします。このとき、頂点の識別番号を使って縦横それぞれに -1 ~ 1 の範囲に収まる値を生成し、正しく頂点がレンダリングされるようにしておかなくてはいけません。
先ほどの頂点シェーダのコードを振り返りつつ、落ち着いて考えてみましょう。まず定数 frag
は、一辺の長さが 2 ですから 1.0 / 2.0 = 0.5
になりますね。そして texShift
のほうは 0.5 * 0.5 = 0.25
です。それぞれ定数を置き換えて、先ほどの pu
と pv
に値を代入している部分の式を考えてみてください。
pu と pv の算出ロジック
float pu = fract(index * frag) * 2.0 - 1.0;
float pv = floor(index * frag) * frag * 2.0 - 1.0;
仮に index
が 0 だとすると、どうなるでしょうか。これは簡単ですね。式をよく見てみればわかるとおり、仮に index
が 0 だとすれば pu
も pv
も、いずれも -1 になりますね。
同様に、仮に index
が 2 だとしたらどうなるでしょうか。この場合 pu
は同じように -1 になりますが pv
のほうは 0 になるはずです。つまり、先ほどの図にここで計算した結果をあてはめると次のようになります。
頂点が 4 つの場合の各頂点の計算結果
このように、各ピクセルに対して角にくっつくような形の座標の配置になりました。きちんと相対的な位置が計算結果として取得できていることがわかりますね。
しかし、この結果をそのまま描き込みに使ってしまうと、たとえば 3 のインデックスの場合などはすべての領域にかぶさる位置が座標として設定されてしまっており、どのテクセルに描き込まれるかが不定になってしまいます。そこで、先ほど登場した定数 texShift
の出番です。
最終的に gl_Position
に値を代入するところのコードを見てみましょう。
gl_Position への代入箇所
gl_Position = vec4(pu + texShift, pv + texShift, 0.0, 1.0);
このとおり、最終的に texShift
を加算してから代入しています。このようにすることで、各テクセルに対してしっかり中心部分へのオフセットがかかるわけですね。イメージとしては以下のようになるわけです。
オフセット適用後の描き込み位置
今回のように、頂点を特殊な計算によって正確な位置へレンダリングしたい場合には、このオフセットさせてテクセルの中心にしっかり狙って描き込むというのは非常に大切です。これをしないと、境界となるような微妙な位置を指定してしまうことになり、動作が不安定になってしまいます。
さて、このような紆余曲折を経て、今回のサンプルの場合は一辺が 16 ピクセルのテクスチャに頂点の座標位置が色として描き込まれます。フラグメントシェーダ側では、単純に varying 変数として入ってきた色を出力するだけで大丈夫です。簡易なものなのでシェーダのコードは掲載しません。フラグメントシェーダによって色が描き込まれると、以下の画像のような状態のテクスチャが準備できることになります。
あとは、もう一つのシェーダ側で、この色を読み出して頂点座標に変換してやる処理を実装すればいいわけですね。
レンダリングされた頂点座標位置の拡大図
それと、勘のいい人なら気がついたかもしれませんが、先ほどの頂点シェーダでは、レンダリングされる点のサイズを意図的に指定するようにしています。
レンダリングされる点のサイズ指定
gl_PointSize = 1.0;
このことからわかるように、javascript 側からはドローコールの際に gl.POINTS
を利用します。また、そもそも 1 ピクセルサイズの点をレンダリングするのであれば gl_PointSize
をシェーダ内で記述する必要性はないのではと思うかもしれません。しかし、指定しない場合には点が正しくレンダリングされない場合があることを覚えておきましょう。ハードウェア依存で点がレンダリングされたりされなかったりするので、しっかりと 1.0 で点の大きさを指定するようにしましょう。
varying 変数 vColor への代入について
ここで補足として先ほどの頂点シェーダで varying 変数 vColor
へ代入を行っている部分について触れておきます。
先ほど掲載したコードを見ればわかるのですが、今回は頂点位置を正規化したあと、1 足して 2 で割るような処理をしています。足して割る処理を行っているのは、単純に -1 から 1 の範囲にある頂点の位置を 0 から 1 の範囲に収めるための措置です。
また、今回はモデル自体が半径 1 の球体、つまり単位球です。この場合、わざわざ正規化する必要性は本来ありません。ですが、テクスチャになにかしらの値を代入する際には、特殊なテクスチャを用いている場合を除いて正しく範囲を調整する癖をつけるようにしましょう。
float texture のような、本来の色の範囲を超えても処理できるテクスチャフォーマットを利用している場合でも、環境によっては強制的に 0 ~ 1 の範囲への丸め処理が起こる場合があるようです。この点には十分注意しましょう。
テクスチャから座標を復元
さて、頂点の座標位置をテクスチャに描き込むことができたら、次はその値を取り出す処理をシェーダに実装しましょう。先ほどとは違い、テクスチャから座標の位置を読み出すシェーダのソースを見てみます。
座標読み出し用頂点シェーダ
attribute float index;
uniform mat4 mvpMatrix;
uniform sampler2D texture;
const float frag = 1.0 / 16.0;
const float texShift = 0.5 * frag;
void main(void){
float pu = fract(index * frag + texShift);
float pv = floor(index * frag) * frag + texShift;
vec3 tPosition = texture2D(texture, vec2(pu, pv)).rgb * 2.0 - 1.0;
gl_Position = mvpMatrix * vec4(tPosition, 1.0);
gl_PointSize = 16.0;
}
さあ、未だかつて一度も登場したことのない、奇妙なシェーダのコードが出てきました。
今までは、どんな場合でも必ずあった、あるモノがこの頂点シェーダにはありません。それがなんだかわかるでしょうか。
それは、頂点の位置に関する attribute 変数です。
従来の頂点シェーダでは gl_Position
に何かしらの座標を出力しなければならないという頂点シェーダの仕組み上、どのようなケースでも必ず attribute 変数を使って頂点座標位置を VBO を通じて受け取るようになっていました。
しかし、今回はテクスチャから読み出した値を頂点の位置として利用することになりますので、頂点座標位置に関する入力がシェーダ側にないという奇妙な状態になっています。
その代わりにと言っては変ですが、先ほども登場した頂点ごとにユニークな識別番号であるインデックスを attribute 変数として受け取ります。テクスチャへの描き込みを行ったときとは逆の変換処理を行って、インデックスから読み出すべきテクスチャ座標を算出するわけです。
先立って登場したシェーダと似たような構文になっているのが、ソースを見るとわかると思います。変数 pu
と pv
を利用し、描き込み時同様、オフセットさせながらテクスチャを参照します。この処理こそが、頂点シェーダによるテクスチャの参照であり、すなわち頂点テクスチャフェッチですね。
テクスチャから読み出した値を、一度 vec3
型の変数に格納した後、uniform 変数として入ってきた座標変換行列 mvpMatrix
と掛け合わせています。テクスチャから正しく値が読み出せていれば、もともとの球体モデルの本来の頂点座標位置に点がレンダリングされるはずです。この頂点シェーダ自体には、けして頂点位置が直接入ってきているわけではありません。テクスチャから読み出した値がその代役として機能しているわけです。なんだか不思議な感じがしますね。
最終レンダリング結果では、点のサイズを若干大きめにしてあります。この点のサイズもハードウェア依存なので、万が一、16 ピクセルでうまくいかない場合にはハードが表現可能な点のサイズを超えてしまっている可能性も考えられます。ただ、現行の PC に限って言えば 16 ピクセルはほぼ間違いなくレンダリングできるサイズなので大丈夫なはずです。
点の描画に関しては以前のテキストでも詳しく解説していますので、わからない方はそちらも確認してみるといいでしょう。
参考:点や線のレンダリング
ちなみに、上記の頂点シェーダと対になるフラグメントシェーダは以下のような感じになっています。
フラグメントシェーダのソース
precision mediump float;
uniform sampler2D texture;
void main(void){
gl_FragColor = texture2D(texture, gl_PointCoord);
}
はい、このように特別なことはしていません。
ただし、16 x 16 サイズの、頂点位置を中継したフレームバッファの内容をそのままテクスチャとして使っています。これにより、最終的なレンダリング結果は当テキストの冒頭にあったような、若干カラフルな仕上がりになっています。
まとめ
頂点テクスチャフェッチ、すなわち VTF について長々と解説してきましたがいかがでしたでしょうか。
頂点シェーダで頂点の座標を受け取らないという世にも奇妙なテクニックを使いつつ、しっかりと頂点の位置が復元される様子は理屈の上では理解していても、なんだか不思議です。
また、今回はフレームバッファを用いて動的に座標を描き込むように処理しましたが、当然のごとくあらかじめ画像として頂点に関する情報を準備しておき、それをテクスチャとして読み込んで利用することももちろん可能です。
今回の場合は小規模なモデル、しかも頂点の座標位置だけを扱いましたので VTF による恩恵という面では無いに等しいでしょう。しかし、冒頭でも説明したように、大量のデータを必要とするスキニングなどの場面では大いに活躍してくれる可能性を秘めている技術だと思います。
趣旨が異なるので詳しくは触れませんが、VTF を用いればディスプレースメントマッピングなどを実装することも可能です。いずれ、WebGL でもこのような技術が一般的なものになる日がくるでしょう。今のうちに、基本を押さえておくことはけして無駄にはならないと思います。
今回も実際に動作するサンプルを以下のリンクより確認できます。ぜひ、体験してみてください。