インスタンシング(instanced arrays)

実行結果

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

またも拡張機能

前回は、異方性フィルタリングを用いてテクスチャに掛かる補間のクオリティを向上させるテクニックを解説しました。

拡張機能として実装されているため、既定では利用できないのが難点ですが、実行結果には歴然の差が生まれたのは個人的に驚きでした。近年のハイクオリティな 3D シーンでは当たり前のように利用されているようですので、覚えておいて損はないと思います。

さて、今回ですが前回に引き続き拡張機能の中からインスタンシングを用いたレンダリング(ANGLE_instanced_arrays を利用したレンダリング)について解説したいと思います。こちらもやはり前回同様に拡張機能です。つまり有効化しないと WebGL では利用できません。しかし、演出するシーンの種類によってはとてつもない効果をもたらす可能性を秘めています。

それではそもそも、インスタンシングとはいったいなんなの? というところから見ていきましょう。

効率よく大量のモデルを描く

3D プログラミングの世界で言うインスタンシング(instancing)は、その名前からも連想できると思いますが、インスタンスを用いた効率的なレンダリングを行う一連の処理のことを言います。あらかじめ、インスタンスを事前に生成しておいて、実際のレンダリングの段階ではそのインスタンスを利用することで、同じモデルを効率よくレンダリングすることができるという技術です。

3D プログラミングでは通常、なにかしらのモデルをレンダリングするためのドローコールはボトルネックになりやすい処理と言われています。これは、ドローコールのたびに CPU と GPU の間でデータのやりとりが発生することなどが主な原因ですが、ドローコールは少なければ少ないほど、負荷の少ないプログラムを作ることができるというのが一般によく言われます。

ドローコールを減らしたほうがいいというのは、もちろん WebGL においても同じです。

たとえば、まったく形状の同じモデル(同じ頂点データのモデル)を、一度に大量にレンダリングしなければならないという場面を想定してみましょう。

従来のやり方をそのまま踏襲するなら、uniform 変数として座標変換行列などを適宜送ってやりつつ、その都度ドローコールを行う必要がありましたよね。ドローコールというのは、たとえば drawArrays drawElements などがそうです。

平行移動や回転などを適用した座標変換行列をシェーダにプッシュし、ドローコールでオブジェクトをレンダリングする。この一連の処理を描きたいオブジェクトの回数分だけ繰り返さなくてはならなかったわけです。この場合、単純にレンダリングしたいオブジェクトの個数と、ドローコールの回数がイコールになります。オブジェクトの数が増えれば増えるほど、連動してドローコールの回数も増えてしまいます。

では、インスタンシングを用いたレンダリングではいったいどのような処理の流れになるのでしょう。

インスタンシングの場合、まず任意の数のインスタンス用データを先に用意しておきます。たとえば、100 個のモデルをレンダリングしたいが、それぞれ色が異なっている……ということであれば、100 種類分の色に関するデータを先に用意しておくわけですね。

用意した色データは、後述する方法を用いて、レンダリングよりも早い段階でシェーダに送ってしまいます。そして、肝心のドローコールはたったの一度だけ呼び出せば OK です。事前にシェーダに送られていたインスタンスのデータが利用され、色の異なる 100 個のモデルが、一度のドローコールでスクリーンに描き出されます。※正確には、座標位置なりなんなりもインスタンス化しないと、全部重なってレンダリングされちゃいますけどね(笑)

このように、インスタンスを利用することでドローコールの回数をかなり節約できます。また、シェーダにデータをプッシュする回数を劇的に減らすことができるので、そういった意味でも負荷の軽減が期待できるでしょう。

なんで標準機能じゃないんだろうかと思ってしまうほど、便利ですよね。

拡張機能の有効化とデータの準備

さて、それでは実際にインスタンスを利用したレンダリングを行うための手順を見ていきましょう。

まずは、なにを差し置いても拡張機能を有効化しましょう。今回は次のようにして初期化します。

拡張機能の有効化

// 拡張機能を有効化
var ext;
ext = gl.getExtension('ANGLE_instanced_arrays');
if(ext == null){
	alert('ANGLE_instanced_arrays not supported');
	return;
}

ANGLE_instanced_arrays という文字列を渡し getExtension を呼び出します。戻り値は、しっかり変数に取得しておきましょう。

もしここで初期化が成功しない場合には、残念ながら拡張機能は使えません。割と最近のマシンであれば大丈夫だとは思います。

さて、続いてはインスタンスのデータを準備します。

今回のサンプルでは、毎度お馴染みのトーラスを豪勢に 100 個出します。頂点座標を個別に移動させるためのデータ、そしてそれぞれのトーラスに異なる色を付けるためのデータ、この二種類のデータを事前に準備します。

インスタンスに関するデータを準備する

// 各インスタンスに適用するデータ

// インスタンスの数
var instanceCount = 100;

// インスタンス用配列
var instancePositions = new Array();
var instanceColors = new Array();

// 配列用のストライド
var offsetPosition = 3;
var offsetColor = 4;

// ループしながらインスタンス用データを配列に格納
for(var i = 0; i < instanceCount; i++){
	// 頂点座標
	var j = i % 10;
	var k = Math.floor(i / 10) * 0.5 + 0.5;
	var rad = (3600 / instanceCount) * j * Math.PI / 180;
	instancePositions[i * offsetPosition]     = Math.cos(rad) * k;
	instancePositions[i * offsetPosition + 1] = 0.0;
	instancePositions[i * offsetPosition + 2] = Math.sin(rad) * k;
	// 頂点カラー
	var hsv = hsva((3600 / instanceCount) * i, 1.0, 1.0, 1.0);
	instanceColors[i * offsetColor]     = hsv[0];
	instanceColors[i * offsetColor + 1] = hsv[1];
	instanceColors[i * offsetColor + 2] = hsv[2];
	instanceColors[i * offsetColor + 3] = hsv[3];
}

上記のように、javascript の配列にインスタンス用のデータを準備します。今回は、先述のとおり頂点座標位置と、頂点カラー、この二つのデータを配列に 100 セット分入れておくわけですね。

途中、配列用のストライドというコメントが出てくる部分がありますが、シェーダ側でどのようなデータ型で情報を受け取るのかをしっかり考えて配列の要素数を調整するようにしましょう。今回の場合、頂点座標位置は vec3 で、頂点カラーは vec4 でシェーダ側で受け取ります。それに合わせた配列の要素数になるように、調整されているのがわかると思います。

ループ構造の中でやっていることは、頂点位置に関しては円を描くように座標を配置しつつ、10 個ごとに原点からの距離を伸ばしています。色のほうは自作関数を呼んで HSV カラーに変換しているだけですね。

さて、これでインスタンスの元となるデータ配列は準備できました。

続いては、この配列から VBO を生成します。

VBO を生成する方法は、従来となにも変わりません。当サイトのオリジナル関数である create_vbo に放り込めば、そのまま VBO が出来上がってきます。

VBO の生成

// 配列からVBOを生成
var iPosition = create_vbo(instancePositions);
var iColor = create_vbo(instanceColors);

ちょっと特殊な処理になるのはここからです。

VBO が生成できたら、これをシェーダに送るためにいくつかのメソッドを呼び出します。

とは言っても、途中までは今までの attribute 系の処理と流れは同じです。

インスタンス用 VBO の登録

// インスタンス用の座標位置VBOを有効にする
gl.bindBuffer(gl.ARRAY_BUFFER, iPosition);
gl.enableVertexAttribArray(attLocation[2]);
gl.vertexAttribPointer(attLocation[2], attStride[2], gl.FLOAT, false, 0, 0);

// インスタンスを有効化し除数を指定する
ext.vertexAttribDivisorANGLE(attLocation[2], 1)

// インスタンス用の色情報VBOを有効にする
gl.bindBuffer(gl.ARRAY_BUFFER, iColor);
gl.enableVertexAttribArray(attLocation[3]);
gl.vertexAttribPointer(attLocation[3], attStride[3], gl.FLOAT, false, 0, 0);

// インスタンスを有効化し除数を指定する
ext.vertexAttribDivisorANGLE(attLocation[3], 1)

頂点座標位置と、頂点カラー、それぞれについて行っているので行数が多いですが、実質は 4 行でインスタンスひとつ分の処理になりますね。

まず、VBO をバインドし enableVertexAttribArray vertexAttribPointer を呼び出します。ここまでは普通に VBO を登録しているだけです。

その下、拡張機能オブジェクトのメソッドである vertexAttribDivisorANGLE を使っているところがポイントです。

ここで、インスタンスが有効になると同時に、除数が設定されます。

ちょっとわかりにくいので、再度下記に当該コードを抜粋します。

当該コードを抜粋

ext.vertexAttribDivisorANGLE(attLocation[2], 1)

このメソッドの第一引数には、VBO からデータを送る attribute 変数のロケーションを渡します。今回のサンプルの場合は、配列変数 attLocation のインデックス 2 が頂点座標位置、インデックス 3 が頂点カラーです。

そして、第二引数に整数で指定する除数というのがちょっとわかりにくいと思うのですが、これはインスタンスをどのように扱うのかを表す重要な数字です。

当テキストの冒頭には、小さなトーラスがずらりと並んだ画像が表示されていたと思います。あの実行結果のサンプルは、この除数に 1 を指定した場合のレンダリング結果です。

頂点座標も頂点色も除数を 1 でインスタンスした場合

頂点座標も頂点色も除数を 1 でインスタンスした場合

では、この除数に 1 以外の数値を指定するとどうなるのかというと、例として以下のようなことができたりします。

あくまでも、一例です。

除数を変更したレンダリング結果の一例

除数を変更したレンダリング結果の一例

トーラスの色の付き方がまったく違うものになっているのがわかるでしょうか。

上の画像は、頂点カラーのインスタンスを有効化する際に、除数に 3 を指定した場合のレンダリング結果です。

除数を変更してインスタンスを登録する

ext.vertexAttribDivisorANGLE(attLocation[3], 3)

このように、第二引数に与える除数を変更しただけで、レンダリング結果はまったく違うものになりました。先ほどの、除数を 3 にした場合のレンダリング結果をさらに拡大してみます。

除数 3 のレンダリング結果拡大図

除数 3 のレンダリング結果拡大図

赤からオレンジ、そして黄色、黄緑、緑……と、画像で言うと反時計回りに、順番に色が移り変わっていくわけですが、その変化が三つおきになっているのがわかりますね。※中央から見ていくとわかりやすいですね

このように、インスタンスに値を適用する際、どのようなルールでインスタンスの値をレンダリングするモデルに対して適用していくのか。このルールを決めるのが除数です。除数というのはあくまでも引数の英語表記をそのまま日本語に直訳しただけの言葉なので、イメージするとすればむしろ[ イテレータを進めるオフセット値 ]とか、[ インスタンスをひとつ進めるまでのカウント数 ]というふうに覚えたほうがいいでしょう。

ちょっとわかりにくい概念も出てきたと思いますが、要は、インスタンスを有効化して、インスタンスがいくつのモデルごとに繰り上がっていくのかを除数で決める、そういったイメージです。

インスタンスの無効化

先ほどから解説している vertexAttribDivisorANGLE を用いれば、インスタンスを有効化することと、除数の設定とを行うことができます。

このメソッドは、インスタンスを単に無効化させるのにも利用でき、この場合は第二引数の除数を 0 に指定します。ドローコールが終わったら意図的に 0 で無効化する――といった面倒なことはする必要はありません。ただ、何かしらの理由で意図的にインスタンスを無効化したいときには、このような方法が使えますので覚えておくといいでしょう。

たった一度のドローコール

さて、レンダリングの前段階でインスタンスを有効化してあれば、あとは一度だけドローコールを呼び出せば、一気に複数のモデルがレンダリングされます。

ドローコールは、インスタンスを用いる場合専用のものを利用します。

インスタンスレンダリング用ドローコール

// インスタンスをレンダリングするドローコール
ext.drawElementsInstancedANGLE(gl.TRIANGLES, indexLength, gl.UNSIGNED_SHORT, 0, instanceCount);

かなり横に長いですが、その実、そんなに難しくはありません。

拡張機能オブジェクトのメソッドである drawElementsInstancedANGLE を使うことで、インスタンスを用いたレンダリングが可能です。これがインスタンス版のドローコールなんですね。

ちなみに、上記のコードはインデックスバッファを用いた場合用で、インデックスバッファを用いない場合には ext.drawArraysInstancedANGLE が利用できます。

引数も、ほとんどが通常の drawElements などのメソッドと同じなので案外難しくはないと思います。最後の第五引数には、インスタンスの数を整数で渡します。今回のサンプルの場合は、ここが 100 になっているので、100 個のトーラスがレンダリングされるというわけです。

当然、第五引数にそれよりも小さな値を渡せば、一部だけインスタンス化させることもできます。

たった一度のドローコールで、複数のモデルがレンダリングされるというのは、なんとなくわかっていても不思議な感じがしますね。

シェーダ側のソース

ここまでの一連の流れを見れば、インスタンスに関するデータは単に attribute 変数としてシェーダに送られることが想像できると思います。

実際のところ、シェーダに関しては記述のルールにこれといった変化はありません。attribute 変数を利用して通常と同じようにシェーダの記述を行えば大丈夫です。今回は頂点シェーダのソースを掲載します。フラグメントシェーダは varying 変数として受け取った色情報をそのまま出力しているだけです。

頂点シェーダのソース

attribute vec3 position;
attribute vec3 normal;
attribute vec3 instancePosition;
attribute vec4 instanceColor;

uniform   mat4 mvpMatrix;
uniform   mat4 invMatrix;
uniform   vec3 lightDirection;
uniform   vec3 eyePosition;

varying   vec4 vColor;

void main(void){
	vec3  invLight = normalize(invMatrix * vec4(lightDirection, 0.0)).xyz;
	vec3  invEye   = normalize(invMatrix * vec4(eyePosition, 0.0)).xyz;
	vec3  halfLE   = normalize(invLight + invEye);
	float diffuse  = clamp(dot(normal, invLight), 0.1, 1.0);
	float specular = pow(clamp(dot(normal, halfLE), 0.0, 1.0), 30.0);
	vColor         = instanceColor * vec4(vec3(diffuse), 1.0) + vec4(vec3(specular), 1.0);
	gl_Position    = mvpMatrix * vec4(position + instancePosition, 1.0);
}

今回の場合、何と言っても重要なのは attribute 変数として入ってくる instancePosition と、 instanceColor の使い方でしょう。

今回のサンプルでは、この二つの attribute 変数の中に、インスタンス化したデータが適宜切り替えられながら自動的に格納されてきます。シェーダの main 関数の中では、これらのデータをどのように使いたいか、直観的にコードを書いてしまって問題ありません。

上記の場合、最終的に gl_Position へ値を代入しているところでインスタンスの頂点座標位置データが使われていますね。同様に、色情報はディフューズやスペキュラと合わせて、varying 変数 vColor へ値を代入するところで使われています。

まとめ

さて、インスタンスを用いたレンダリングについて解説してきましたがいかがでしたでしょうか。

当然のことですが、異なる頂点データからなる複数のモデルを、同一のインスタンスに含めることはできないのでその点は注意しましょう。あくまでも、同一形状のモデルを複製する場合に、柔軟に利用できる仕組みの一つとして覚えておくのがいいと思います。

具体的な利用シーンとしては、同じ背格好のキャラクターが大量にマップ上に登場するシーンや、宇宙空間のようなところで同一形状のオブジェクトが大量に浮遊するシーンなど、結構いろいろ応用が利きそうな気がします。

惜しいのは、これが拡張機能という扱いだという点でしょう。しかし、最近のマシンならほぼ動作する可能性が高いことに加え、将来的には標準機能に含まれてくる技術だと思います。今のうちから慣れておくと、必ず役に立つでしょう。

今回は頂点の座標と色という単純なデータのみをインスタンス化しましたが、たとえば行列やクォータニオンなどをうまく活用することで、さらに複雑な動作をインスタンス化で一気に適用しつつレンダリングすることもできます。工夫して、効率よくレンダリングする方法にチャレンジしてみてください。

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

entry

PR

press Z key