instanced arrays と VAO

実行結果

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

こちらも拡張機能からの格上げ

前回は、Vertex Array Object を利用した効率の良いバッファ操作について解説しました。

VAO は WebGL 1.0 のころは拡張機能としてしか利用することができませんでしたが、WebGL 2.0 では標準機能として利用することができるようになりました。記述量を減らすことができるだけでなく、将来的には利用が必須となるであろうことが濃厚な機能になっていますので、ぜひしっかりと身につけておきましょう。

さて今回ですが、やはり拡張機能からの格上げを果たした機能である instanced arrays を利用したインスタンス描画を見ていきます。

こちらもやはり VAO と同じように、非常に便利かつ強力な機能です。時と場合によっては必ずしも必要ではない場合もあるかと思いますが、一度に大量のオブジェクトを描画する必要がある場合にはとても頼りになる機能です。以前、当サイトで拡張機能を利用する手法について解説したことがありましたが、これがそのまま標準で利用できるようになっている感じですね。

参考:WebGL: インスタンシング(instanced arrays)

標準機能として利用できるようになったため、拡張機能を初期化するような手順は一切必要なくなりました。VAO が標準化されたこともありますので、今回は両者を組み合わせて描画を行う方法について解説していきます。

インスタンス描画のメリットをおさらい

さて、簡単にインスタンス描画のメリットをおさらいしておきます。

通常の描画処理の流れでは、複数のオブジェクトを描く場合にはその都度描画命令を発行してやる必要がありました。

たとえば、オブジェクト A は左に、オブジェクト B は右に、それぞれ位置を少し移動させてから描画したい場合などですね。これを実現する方法はまあいろいろあるのですが、普通に考えると、MVP マトリックスのような座標変換行列を複数用意してやり、オブジェクトを動かすのが普通かなと思います。

この場合、当然ですが uniform 変数として MVP マトリックスを複数回シェーダへプッシュする必要があります。またドローコールについても、複数回発行する必要があるでしょう。ですから、オブジェクトが一万個とかあるとしたら、一万回の描画命令が必要になってしまうわけですね。同時に、リアルタイムにループを回しているのであれば、その都度、MVP マトリックスを大量に生成する必要もありますから、CPU 的にもかなりつらい処理になります。

このような状況を改善することができるのが、インスタンス描画の強みです。

instanced arrays を利用したインスタンス描画では、あらかじめオブジェクトに適用される個別の情報を別途生成しておき、描画の際にそれらを参照させるようにすることができます。たとえば一万個のオブジェクトが、それぞれどのような位置関係になるのかがわかっているのであれば、それをあらかじめ「オフセットする移動量のデータ」として用意しておき、描画時に適用できるわけです。

ただし、インスタンシングが行えるのは、あくまでも同じ頂点データ(頂点モデル)を描く場合に限られます。

ですから、たとえば森のような景色を描画するために、木のモデルを大量に描かなければならない場合など、単一の頂点モデルを複数回描画する際にインスタンシングが力を発揮します。異なる形状の、別々の頂点モデルを大量に複製できるわけではないので、その点は勘違いしないように気をつけましょう。

インスタンス化するデータの準備

さて、インスタンス描画がどのような技術なのかが理解できたでしょうか。

冒頭で必ずしも使う必要があるとは限らないみたいなことを書きましたが、同じ形状の頂点データを大量に複製するための技術なので、バリエーション豊かな複数のオブジェクトを描く場合や、オブジェクトの数が極端に少ない場合だと、あまりインスタンス描画の恩恵は得られません。

今回はシンプルなケースとして、トーラスと球を異なる座標位置に配置しながら、複製しつつ描画するやり方を見てみましょう。

まず、インスタンス化を行う際に、たとえば複数の異なる座標にオブジェクトを配置するとしたら、どのような情報をインスタンス化する必要があるかを考えてみましょう。

この場合、描画される各オブジェクトが「ローカル座標系で異なる位置」に始めから置かれていたような感じにできれば、別々の位置に描画されることがわかりますね。原点に最初から置いてあるオブジェクトと、Z 方向に 5.0 移動した位置に置いてあるオブジェクトがあるなら、両者は同じ MVP マトリックスを適用しても、別々の位置に置かれた状態で描画されるはずですよね。

ですから、今回のようなケースでは vec3 のデータを大量に用意し、そこに各オブジェクトがどの程度オフセットされて描画されるのか、そのオフセット量を XYZ で定義して格納しておけばいいことがわかります。サンプルのなかで実際にそのオフセット量を定義している箇所を見てみましょう。

インスタンス化するオフセット量の定義

// position offset
var positionOffset = [];
(function(){
    var i, j;
    for(i = 0; i < 9; ++i){
        j = i - 4;
        positionOffset.push(j, j, j);
    }
})();

上記の場合、0 番から 8 番まで、全部で 9 個のインスタンス用オフセットデータを作っています。変数 j にはループ内で −4 から 4 までの範囲でデータが格納されるので、XYZ にその分だけオフセットする量として定義される感じですね。

続いて、ここで定義したオフセット量を使って VBO を生成します。

VBO を生成する際は、それがインスタンス化するべきデータである場合、除数を同時に指定してやる必要があります。

この除数についてはちょっと概念がわかりにくいかと思いますが、簡潔に言うと「オブジェクトいくつに対してインスタンスのインデックスを繰り上げるか」というふうに考えるとわかりやすいでしょう。

もし除数に 1 を指定したのであれば、描くオブジェクトひとつひとつで、インスタンス化したデータが繰り上がっていきます。仮に 2 などの除数を指定すると、描かれるオブジェクトがふたつ増えるごとに、参照されるインデックスが繰り上がっていきます。今回の場合は全てのオブジェクトに対して異なる座標を与えることになるので、除数としては 1 を指定すればいいことになります。

さて、この除数の概念を踏まえつつ、どのように VBO を生成しているのかを見てみます。

VBO 生成のための関数

// torus の頂点データを生成するユーティリティ関数
var torusData = torus(32, 32, 0.1, 0.5);

// ユーザー定義関数をコールして VAO を生成する
var torusVAO = create_vao_instance(
    [torusData.p, torusData.n, torusData.t, positionOffset],
    [null,        null,        null,        1             ],
    attLocation,
    attStride,
    torusData.i
);

// VAO とインスタンシングを同時に行うユーザー定義関数
function create_vao_instance(vboDataArray, vboDivisorArray, attL, attS, iboData){
    var vao, vbo, ibo, i;
    // VAO の生成とバインド
    vao = gl.createVertexArray();
    gl.bindVertexArray(vao);

    // 引数で与えられた配列からループで VBO を生成していく
    for(i in vboDataArray){
        vbo = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vboDataArray[i]), gl.STATIC_DRAW);
        gl.enableVertexAttribArray(attL[i]);
        gl.vertexAttribPointer(attL[i], attS[i], gl.FLOAT, false, 0, 0);

        // 除数の指定がある場合だけ除数を登録する
        if(vboDivisorArray[i]){
            gl.vertexAttribDivisor(attL[i], vboDivisorArray[i]);
        }
    }

    // インデックスバッファがある場合はこれも VAO に登録
    if(iboData){
        ibo = gl.createBuffer();
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Int16Array(iboData), gl.STATIC_DRAW);
    }
    gl.bindVertexArray(null);
    return vao;
}

さてちょっと縦に長いですが、臆せず見ていきましょう。

まず大前提として、ここで VAO に関連して行っている処理の流れは、実はこれまでとなにも変わっていません。VBO や IBO を適宜生成して、VAO にアタッチしているだけです。

インスタンス化に独特な処理を行っているのはループ処理のなかで、除数の指定があるかどうかを確認し、それが指定されていた場合だけです。コードをよく見ると、除数がある場合だけ異なる処理をしている箇所があるのがわかると思います。

そこではひとつだけ、インスタンス化に特有なメソッドを呼んでいます。それが gl.vertexAttribDivisor です。これが先ほど説明した除数を指定するためのメソッドですね。逆に除数を必要としない、つまりインスタンス化する必要が無い頂点データに対しては、これを呼ばなければいいだけです。掲載したコードの行数はちょっと多いですが、実はすごく単純です。インスタンス化したい場合は、たったひとつ、メソッドを呼べば済むわけですね。

コードをよく観察するとわかるかと思いますが、ユーザー定義関数の create_vao_instance では、除数を指定するための配列を受け取るようになっていて、この中身が真として判断される場合に限って vertexAttribDivisor を呼ぶようになっています。

通常の、頂点座標や法線などは普通に VBO を生成して VAO にアタッチするだけです。除数の指定があった場合だけ、別途追加で vertexAttribDivisor をコールして除数を登録しているんですね。

このように処理を行うと、VAO には各種 VBO や IBO と共に、インスタンス描画のためのインスタンス化に必要な情報がセットされます。あとは、VAO をバインドしてからドローコールを発行すると、シェーダ内部では除数を指定した頂点データのみが、インスタンス化された状態で参照できるようになります。

実際に使っているシェーダのコードは以下のような感じ。

インスタンス描画を行うシェーダ

#version 300 es
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoord;
layout (location = 3) in vec3 offset;

uniform mat4 mMatrix;
uniform mat4 mvpMatrix;
uniform mat4 normalMatrix;

out vec3 vPosition;
out vec3 vNormal;
out vec2 vTexCoord;

void main(){
    vec3 pos = position + offset;
    vPosition = (mMatrix * vec4(pos, 1.0)).xyz;
    vNormal = (normalMatrix * vec4(normal, 0.0)).xyz;
    vTexCoord = texCoord;
    gl_Position = mvpMatrix * vec4(pos, 1.0);
}

こちらもやや行数が多いですが、注目すべきは attribute 変数の location 番号 3 番に指定されている offset です。

シェーダの main 関数を見てみると、頂点のローカル座標を表す positionoffset を加算してから、それを使った行列処理などが行われているのがわかると思います。

除数の指定を行った頂点データは、シェーダ内ではその除数の指定に従ってインスタンスが適宜変更され、利用されることになるんですね。

慣れるまでは、シェーダ内でのデータ参照をイメージするのがちょっと難しいかもしれないですが、除数を指定された頂点データは、その振る舞い方が他の頂点データとは異なる状態になるので、落ち着いて、挙動を確認してみてください。

まとめ

さて、VAO とインスタンス描画の併用について見てきましたが、いかがでしたでしょうか。

個人的には、WebGL 全般に言えることだと思いますが、バインド関連の処理が非常に手間になる場合が多いので、最初にドカッとまとめてインスタンス化する情報も一緒に登録できるのは非常に便利だと思います。冒頭でも書いたように、インスタンス描画については必要な状況が限定されるところがあるので、ただ単に使っていればそれでいいという機能ではないと思いますが、逆に状況によっては非常に強力な機能でもあると思います。

まずはシンプルで簡単なサンプルを使ってその挙動を確かめておき、実際に動作する状況まで持っていけたら、一気に数十万のオブジェクトをインスタンス化して描画してみるなど、その効果を検証してみるといいでしょう。

使いこなすことで強力な武器となる可能性を秘めたインスタンシング。拡張機能から標準機能に格上げされ、より使いやすくなっています。ぜひ頑張って習得を目指してみてください。

実際に動作している様子は、以下のサンプルから確認することができます。

entry

PR

press Z key