[WebGL] 頂点テクスチャフェッチを使って、テクスチャからデータを読みだしてみる

  • 15
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

例によってwgldさんの記事を参考にさせて頂いています。
(いつもありがとうございます)

そして例によって今回もサンプルをjsdo.itにを上げてあります。

sample.jpg

頂点テクスチャフェッチ(VTF)とは

読んで字のごとく、頂点シェーダ内でテクスチャをフェッチする機能です。
普通はテクスチャは画像であり、そのためフラグメントシェーダで参照されるのが基本です。

が、デプスバッファシャドウなどのように、テクスチャは単純な画像としてだけではなく、様々なデータのやり取りのハブとして使うことができます。

なぜなら、テクスチャの1ピクセルは8bit x 4(RGBA)の情報量を扱うことができます。つまり合計で32bitですね。
総ピクセル数はテクスチャのサイズに比例するので、テクスチャのサイズを大きくしていけばそれだけ格納できる情報量も増えていくことになります。

浮動小数点テクスチャも

WebGLでは拡張機能を使わないとなりませんが、通常8bitであるテクスチャを32bit x 4 = 128bitとして扱える「浮動小数点数テクスチャ」機能もあります。
こちらであれば、さらに精度の高い情報の受け渡しが可能となります。
(ただ、GPUの実情を考えると正常に動作するのは16bitくらいまでの精度になるようです。wgldさんの記事を参照)

なぜわざわざテクスチャ?

先ほどから情報量という言葉を使っていますが、なぜテクスチャを使うのでしょうか。
実は、GPUが扱えるデータの量には制限があり、uniform変数も決して豊富な量を扱うとは言えません。
wgldさんの記事から引用させてもらうと、

シェーダが受け取れる uniform 変数の数は、2014 年 3 月現在では PC でもベクトル単位で 250 程度が現実的な範囲だと思います。もちろん、優れた性能を持つグラフィックボードなどを搭載していれば、1000 以上のベクトルを扱うことができるはず。ただしその逆も然りで、一昔前の PC やタブレット、スマートフォンなどでは確実にもっと少なくなるでしょう。

とのこと。
ベクトル単位で250ということは、簡単に言えばvec4型の変数が最大でも250程度しか宣言できない、ということです。

さらにmat4型ともなれば、4つ分のベクトルが必要になるので、さらに宣言できる数が減ってしまいます。

通常のプログラムからした相当に少ないことが分かると思います。
そしてこの問題を解決する手段が、今回の主題であるテクスチャなんですね。

なにに使うの?

データ容量として申し分ないのは分かりました。
が、実際問題としてなにに使うのでしょうか。

テクスチャはデータの塊と見ることができるので、取り扱いさえしっかりできればなんにでも利用することができるでしょう。
具体的な例を上げると、以前書いた([WebGL] スキニングメッシュ(ボーン)の仕組みを自前で実装してみる)で、 ボーン行列 なるものが出てきました。
この「ボーン行列を格納する」という用途にも使えます。

これに関してはこちらの記事(OpenGLでGPUスキニング【頂点テクスチャ編】)が参考になりました。
(WebGLではなくてOpenGLの記事ですが)

要は、 CPU←→GPUとのデータの受け渡しに利用できる、というわけです。

どう使うの?

さて、データの受け渡しに使える、というのが分かりました。
が、ではどうやってデータの受け渡しをするのでしょうか。

と、その前に。
実は 頂点テクスチャフェッチは、GPUがサポートしている必要 があります。

対応しているかどうかは以下のようにすることで調べることができます。

// 頂点テクスチャフェッチが利用可能かどうかチェック
var info = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS);
if(info > 0){
    console.log('max_vertex_texture_imaeg_unit: ', info);
}
else {
    alert('VTF not supported');
    return;
}

gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS);を実行すると、頂点で扱えるテクスチャのユニット数が返されます。
これが1以上あれば利用可能、ということが分かります。

データの準備

では実際にデータを準備し、それを利用してみます。

data
var position_data = new Uint8Array([
    255, 255,   0, 255,
      0, 255,   0, 255,
      0,   0, 255, 255,
    255, 255, 255, 255,
]);

データは適当ですw
大事なポイントは3つ。

  1. Uint8Array(ArrayBufferView)を使ってデータを準備する
  2. データは1次元配列で準備する
  3. 255で除算されるので、それを考慮する

正直(3)については色々調べてみたものの、本当にそうなのかは分かりません;
ただ、シェーダ内で値を参照するとそういう挙動をしているのと、Uint8Arrayを使っているから8bit換算でなにかしらあるのかなーと思っています。

  • 大事な点1。ArrayBufferView(あるいはTypedArray)を使ってデータを準備します。
  • 大事な点2。データは1次元配列で準備します。

HTML5のCanvas要素を使ったことがある人は知っているかもしれませんが、CanvasのメソッドでgetImageDataというメソッドがあります。
これはCanvasに描かれている画像のデータを得るメソッドです。

得られるデータの中に画像のピクセルごとのRGBAを表す配列が含まれていますが、これがまさに1次元の配列になっています。
なのでデータ自体は[r, g, b, a, r, g, b, a, r, g, b, a, ....]という形でRGBAの順に連続してデータが並んでいるわけです。

データはこういう形式で準備する必要がある、というわけですね。

データをテクスチャに書き込む

最初、テクスチャにデータを格納することとそれを利用するメリットは分かりましたが、肝心のデータ自体の準備をどうしたらいいか分かりませんでした。
が、答えはシンプルで、上記で準備したデータをそのままテクスチャのデータとして指定してしまえばいいのです。

具体的なコード例は以下です。

write-to-texture
// データを準備
var position_data = new Uint8Array([
    255, 255,   0, 255,
      0, 255,   0, 255,
      0,   0, 255, 255,
    255, 255, 255, 255,
]);

// WebGLTextureオブジェクトを生成
var position_texture = gl.createTexture();

// 生成したオブジェクトをWebGLにバインド
gl.bindTexture(gl.TEXTURE_2D, position_texture);

// テクスチャの各種パラメータ設定
// `gl.TEXTURE_MAG(MIN)_FILTER`には`gl.NEAREST`を用いる。
// これは、`gl.LINEAR`を使うと頂点と頂点の間で補間されてしまうため。
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

// テクスチャにデータを設定
gl.texImage2D(gl.TEXTURE_2D,        // Target
                0,                  // Mip map level
                gl.RGBA,            // テクスチャのピクセルフォーマット
                2, 2,               // テクスチャの幅と高さ
                0,                  // ボーダー
                gl.RGBA,            // ピクセルの配列形式(*1)
                gl.UNSIGNED_BYTE,   // 1ピクセルのデータ形式
                position_data);     // ピクセルデータ

*1 ... gl.RGBAがテクスチャで利用されるフォーマットです。[r, g, b, a, ...]と並んでいたあの形式ですね。

大事なポイントはひとつで、gl.texImage2Dの引数の最後で、事前に準備したデータを指定しています。
通常は、画像であるimg要素やCanvas要素、あるいはそれに準ずるものを指定します。
(場合によってはnullを指定して、フレームバッファにアタッチする場合もありますね)

ここに、今回は事前に準備したピクセルデータとしての配列を渡します。
ただ先にも書いた通り、これは画像としてのピクセルデータではなく、CPUとGPUとの間でデータを渡すためのものなので、色ではなくあくまでデータです。
なので、好きなデータを書き込むことができるわけですね。

VTFでデータを読み込む

以上でテクスチャにデータを書き込むことができました。
当然、これをシェーダ内で適切に読みだしてやらなければなりませんね。

まずは実際の頂点シェーダのコードを見てみます。

load-data
attribute vec3 position;
attribute vec3 normal;
attribute vec2 textureCoord;

uniform mat4 MATRIX_MVP;
uniform mat4 MATRIX_INV_M;
uniform sampler2D position_texture;

varying vec4 vColor;
varying vec2 vTexCoord;

vec3 lightPos = normalize(vec3(1.0, 1.0, -1.0));

const float pxSize = 2.0;
const float frag = 1.0 / pxSize;
const float texShift = frag * 0.5;

void main() {
    vec2 asOnePx = vec2(texShift);
    vec3 addPos = texture2D(position_texture, asOnePx).xyz;
    vec3 wnormal = normalize(vec4(normal, 1.0) * MATRIX_INV_M).xyz;
    float diff  = clamp(dot(wnormal, lightPos), 0.1, 1.0);
    vColor = vec4(1.0) * vec4(diff, diff, diff, 1.0);
    vTexCoord = textureCoord;
    gl_Position = MATRIX_MVP * vec4(position + addPos, 1.0);
}

ライティングやら色々やっていますが、大事なところを抜き出すと以下。

point
// 中略

uniform sampler2D position_texture;

// 中略

const float pxSize = 2.0;
const float frag = 1.0 / pxSize;
const float texShift = frag * 0.5;

void main() {
    vec2 asOnePx = vec2(texShift);
    vec3 addPos = texture2D(position_texture, asOnePx).xyz;

    // 後略
}

当然ですが、まずはテクスチャを読み込むために、テクスチャユニットIDを受け取るuniform変数を宣言します。

その下にあるconst宣言は、テクスチャのどのデータを読み出すかを示しています。
const float pxSize = 2.0;は、今回は 2 x 2 のサイズのテクスチャなので2です。

const float frag = 1.0 / pxSize;は、1.0をテクスチャのピクセルサイズで割っています。
これは、シェーダは基本的に-1〜1の範囲で値を扱うためです。
テクスチャの場合はさらに0〜1の間で処理されるので、「1pxあたりの幅 = 1.0 / pixel size」を求めているわけです。
なので今回は1pxは0.5、というわけですね。

そして最後のconst float texShift = frag * 0.5;は、上記で求めた1pxの単位をさらに0.5倍しています。
(これは意味的には2で割っています。割り算より掛け算のほうが若干高速なので、繰り返し処理をする場合は0.5倍したほうが高速、というわけですね)

これを計算する理由は上で説明した通り、ピクセルをフェッチする値が1px、2pxという整数値ではなく、0.5や0.25など0〜1の範囲でアクセスするためです。

つまり、フェッチ対象のピクセルが厳密に1px目、というような判断ができません。

例えるなら

イメージしやすい例(かどうか分かりませんが・・)で言うと、Illsutratorのようなベクターデータを扱うツールで、0.5pxだけずらすとピクセルがにじみます。これと似たような問題がある、というわけですね。

なので、こうしたピクセルのずれを補正するために、さらに半分のサイズの値を求めているわけです。

図にすると以下のようになります。

VTF.png

仮にこの(0.25, 0.25)の位置からずれると、周りの色を含めてサンプリングするため、読み出したい数値との誤差が生じてしまいます。
それを防ぐために、こうした補正処理を行っている、というわけです。

今回の例では

今回はデータをとりあえず読みだす例なので、まったく意味のない計算を行っています。

sample
vec2 asOnePx = vec2(texShift);
vec3 addPos = texture2D(position_texture, asOnePx).xyz;

の部分ですね。
これは、先に書いた補正分の値を使ってvec2型の値を宣言しています。
asOnePxの名前の通り、単純にテクスチャの1px目の値を参照する値を定義しています。
(上記の図で言えば0番の青いピクセル)

あとはその数値を利用して、テクスチャから値をフェッチしている、というわけですね。

sample2
vec3 addPos = texture2D(position_texture, asOnePx).xyz;

実際にはこの読み出す値とテクスチャに格納するデータをしっかりと関連付けてシェーダ内で有効に利用する、というのが流れになるでしょう。

まだどうやってそのあたりの情報を整理をするかは把握できていませんが、できたら「ボーン行列をテクスチャを使って参照する」というのをやってみたいと思います。