はじめに
こっからは応用編です。基本は一通り説明したので、ここからは応用的な話題を取り上げたいと思います。webGLはアトリビュートが無いとほぼ何にもできないです。基本大事。
p5.jsでやります。ここを間借りする利点はいっぱいあります。preloadでリソースを楽々ローディングできること、キャンバスやレンダラーも楽に作れる、取得できる、恒常ループは備え付け、frameCountやrandom,noise,shuffleなどの便利な汎用関数の数々、createGraphicsでオフスクリーンキャンバスも手軽に作れてテクスチャとして利用できる。利点しかないです。更新頻度が高くてバギーなwebGLを差し置いたとしても余りあるほどの利点なので、使わない手はないです。加えてキャンバスとレンダラーさえあれば簡単にネイティブに移行できます。基本を学ぶにはうってつけの環境だと思います。
そういうわけで引き続きp5.jsでやっていきます。
インスタンシングです。はっきり言って超簡単です。それゆえ最初に持ってきました。まあどれも簡単なのであれですが...内容的にはglobalStateのvertexAttributeArrayをちょこっといじってドローコールをちょこっといじるだけです。たったそれだけで、同じオブジェクトが大量に複製されます。インスタンシングはどっちかというと仕組みを整えた人が偉いですね...頭が上がらないです。なんだかんだ言ってwebGLで一番すごいのは仕組みを作った人ですね。つくづくそう思います。
グローバルステート。
globalState = {
currentProgram: null,
bindingBuffer: {
arrayBuffer: null,
elementArrayBuffer: null,
...
},
vertexAttributeArray: [
{enable: false, arrayBuffer: null, layout: undefined, divisor: 0,
current: DEFAULT_VERTEX_ATTRIB},
{},{},{},...
],
...
}
vertexAttribute系の関数は、それぞれ守備範囲が決まっています。
- enable/disable VertexAttribArray: enableのtrue/falseを切り替える
- vertexAttribPointer: arrayBufferにbindingBufferのarrayBufferをコピーし、さらにlayoutを決定する
- vertexAttribDivisor: 今回初登場。divisorの値を決める。0以上の整数
- vertexAttrib/vertexAttribI: currentをいじる。disableの時の値として使われる
そういう感じで役割分担されており、互いに領域を侵犯することはありません。分かりやすくていいですね。今回いじるのはdivisorです。ここは常に0なのですが、ここをいじることでインスタンシングが実行できます。
従来のドローコールはたとえばdrawArraysのTRIANGLESで0,9だったら0,1,2と、3,4,5と、6,7,8で三角形を作って終わりなんですが、
gl.drawArraysInstanced(gl.TRIANGLES, 0, 9, 100);
と命令された場合、これを100回繰り返します。つまり三角形を3つ作る処理を100回やります。同じ値でフェッチして。通常であれば。そうなると同じところにいくつも三角形ができてなんじゃこりゃってなるんですが、そこでdivisorです。
復習すると、アトリビュートとは整数列の整数とバッファのバイト列から値を算出して、バーテックスシェーダで用いて、点の位置や付加属性(補間してフラグメントシェーダで使う)を計算するためのものです。そのための整数は今回、与えられる整数列の整数0,1,2,3,4,5,6,7,8の他に、インスタンスIDというものがあります。100個の場合、これは0,1,2,...,99と連番でつきます。似たような命令に
gl.drawElementsInstanced(gl.TRIANGLES, 9, gl.UNSIGNED_SHORT, 0, 100);
がありますが、これの場合でも常に0,1,2,...,99の連番です。つまり、二つの整数があるわけです。どっちを使ってバッファから数をsize個取り出すか。それを決めるのがdivisorです。つまり整数さえ決まれば、それ以降は通常のアトリビュートと同じわけです。簡単というのはそういう意味です。
結論から言うと、
- divisorが0の場合:ドローコールの整数(要するにgl_VertexID)を使ってフェッチする
- divisorが1以上の場合:インスタンスID(というかgl_InstanceID)をdivisorで割って得られる商を使ってフェッチする(要するにMath.floor(gl_InstanceID/divisor)
ここに書きましたが、フェッチに使う整数をgl_VertexIDで取得できるのと同様に、インスタンス描画では何番目のインスタンスであるかをgl_InstancedIDで取得できます。つまり、位置をずらすためにvec2のアトリビュートを100個分用意すれば、それを使って個別に位置をずらすことで違う位置に大量に描画できるんですね。その際divisorが1なら全部ばらばらになります。色もそうですが、たとえば色でdivisorを5にすれば0,1,2,3,4が同じ色、5,6,7,8,9が同じ色、になったりしますね。
これをインスタンス無しでやろうとすると、三角形300個分の基本の位置が必要になってきますが、インスタンス描画なら基本の位置は3つ分でよく、メモリの節約ができます。オフセットと合わせると、300が、103です。三分の一。インスタンス描画の利点はそこです。
コード全文
// やってみよう。アトリビュート。
// インスタンシング
// 楽。
// https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext/drawArraysInstanced
// 三角形いっぱい描いておわり
function setup() {
createCanvas(400, 400, WEBGL);
const gl = this._renderer.GL;
const vs =
`#version 300 es
layout (location = 0) in vec2 aPosition;
layout (location = 1) in vec2 aOffsetPosition;
layout (location = 2) in vec3 aOffsetColor;
out vec3 vColor;
void main(){
float factor = float(gl_InstanceID)*0.01;
vColor = aOffsetColor * factor;
gl_Position = vec4(aPosition*0.1 + aOffsetPosition, 0.0, 1.0);
}
`;
const fs =
`#version 300 es
precision highp float;
in vec3 vColor;
out vec4 fragColor;
void main(){
fragColor = vec4(vColor*2.0, 1.0);
}
`;
const pg = createShaderProgram(gl, {
vs, fs
});
// まず位置
const pBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-sin(0), cos(0), -sin(TAU/3), cos(TAU/3), -sin(TAU*2/3), cos(TAU*2/3)
]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// インスタンシング用の位置オフセットと色オフセット
const pArray = new Float32Array(200);
const cArray = new Uint8Array(300);
colorMode(HSB,100);
for(let i=0; i<100; i++){
pArray[2*i] = -1 + (i%10)/5 + 1/10;
pArray[2*i+1] = -1 + floor(i/10)/5 + 1/10;
const col = color(i, 100, 100);
cArray[3*i] = red(col);
cArray[3*i+1] = green(col);
cArray[3*i+2] = blue(col);
}
const buf0 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf0);
gl.bufferData(gl.ARRAY_BUFFER, pArray, gl.STATIC_DRAW);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
const buf1 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf1);
gl.bufferData(gl.ARRAY_BUFFER, cArray, gl.STATIC_DRAW);
gl.vertexAttribPointer(2, 3, gl.UNSIGNED_BYTE, true, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// 準備完了
for(let i=0; i<3; i++){
gl.enableVertexAttribArray(i);
if(i>0){
gl.vertexAttribDivisor(i, 1); // 1と2に対して実行
}
}
// 描くか。
gl.useProgram(pg);
gl.clearColor(0.1,0.1,0.1,1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// インスタンシング
gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, 100);
gl.flush();
}
function createShaderProgram(gl, params = {}){
/* 略 */
}
function getActiveUniforms(gl, pg){
/* 略 */
}
function getActiveAttributes(gl, pg){
/* 略 */
}
function setUniformValue(gl, pg, type, name){
/* 略 */
}
実行結果:
インスタンスアトリビュートの用意
基本の位置については、いつもの3つの頂点を用意しています。
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-sin(0), cos(0), -sin(TAU/3), cos(TAU/3), -sin(TAU*2/3), cos(TAU*2/3)
]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
そしてインスタンス用ですが、まあ50000とかでもいいんですが、目的は仕様を知ることなので100で充分でしょう。で、左下から右上に向かって並ぶようにします。また、p5のcolorModeが便利なので、それを使ってRGBレインボーを出しています。
// インスタンシング用の位置オフセットと色オフセット
const pArray = new Float32Array(200);
const cArray = new Uint8Array(300);
colorMode(HSB,100);
for(let i=0; i<100; i++){
pArray[2*i] = -1 + (i%10)/5 + 1/10;
pArray[2*i+1] = -1 + floor(i/10)/5 + 1/10;
const col = color(i, 100, 100);
cArray[3*i] = red(col);
cArray[3*i+1] = green(col);
cArray[3*i+2] = blue(col);
}
位置のオフセットはFLOATで100個分なので200,色のオフセット、というか色はバイトデータで100個分なので300ですね。フェッチの仕方も素直にいつも通りです。0,1,2,...,99でそれぞれフェッチされることを想定してレイアウトを整えています:
const buf0 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf0);
gl.bufferData(gl.ARRAY_BUFFER, pArray, gl.STATIC_DRAW);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
const buf1 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf1);
gl.bufferData(gl.ARRAY_BUFFER, cArray, gl.STATIC_DRAW);
gl.vertexAttribPointer(2, 3, gl.UNSIGNED_BYTE, true, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
なお、位置のオフセットは1で色のオフセットは2を使っています、というかレイアウトで勝手に決めました。
#version 300 es
layout (location = 0) in vec2 aPosition;
layout (location = 1) in vec2 aOffsetPosition;
layout (location = 2) in vec3 aOffsetColor;
out vec3 vColor;
void main(){
float factor = float(gl_InstanceID)*0.01;
vColor = aOffsetColor * factor;
gl_Position = vec4(aPosition*0.1 + aOffsetPosition, 0.0, 1.0);
}
gl_InstanceIDを使って小さい方の色が暗くなるようになっています。ところでコンソールを見た人は気づいたと思いますが、gl_InstanceIDにもロケーションが用意されます。ただし-1です。5124なのでgl.INTですね。ちなみにgl_VertexIDも併用するとこっちのロケーションも-1であることが分かります。どういう風にフェッチしているんだろう...
ポジションにオフセットを足しています。意図した挙動通りなら、小さめの三角形が色違い、位置違いで100個並ぶはずです。それを可能にするのが次のコードです。
// 準備完了
for(let i=0; i<3; i++){
gl.enableVertexAttribArray(i);
if(i>0){
gl.vertexAttribDivisor(i, 1); // 1と2に対して実行
}
}
これだけ。つまり、1番と2番はインスタンス用のアトリビュートなので、divisorを1に設定するわけですね。あとはドローコールで100個描いてくれるようお願いするだけです。
// インスタンシング
gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, 100);
いつものドローコールの末尾にカウント数が加わるだけです。めっちゃ簡単!説明は以上です。
注意:divisorの設定が残ることについて
なお、当然ですがdivisorはvertexAttribute単位で指定するものなので、前の描画の設定が残ってしまい、他の描画に影響を及ぼす可能性がなくはないです。たとえば他のインスタンス描画で2,3,4を使いたい場合、1が操作されなかったとすると1,2,3,4がインスタンス用になってしまいます。その辺りのきちんとした管理はユーザーに委ねられています。描画のたびに関数で0に戻してもいいんですが...
VAOを使えばいいんですよね...
しかしまあそれはおいておいてですね。とても便利な機能なのでぜひ使ってみてください。
補足:エレメント描画の例
せっかくなのでdrawElementsInstancedの例もあげておきます。前回「F」を作ったと思いますが、あれを流用し、改造したものです。アトリビュートで書き直してあります。こっちは行列アトリビュートをインスタンス変数に用いてそれぞれ回転させたうえで位置を移動させています。まだ教えてないBLENDを使っています。こういうのもいずれ説明できたらと思います。
// indexBufferで遊ぼう。
// Fを描く
// インスタンシング
function setup() {
createCanvas(400, 400, WEBGL);
const vs_drawF =
`#version 300 es
layout (location = 0) in vec2 aPosition;
layout (location = 1) in vec2 aOffsetPosition;
layout (location = 2) in mat2 aOffsetRotation;
void main(){
vec2 p = aPosition;
p = (p - vec2(1.5, 2.5)) / 48.0;
gl_Position = vec4((p*aOffsetRotation) + aOffsetPosition, 0.0, 1.0);
}
`;
const fs_drawF =
`#version 300 es
precision highp float;
out vec4 fragColor;
void main(){
fragColor = vec4(0.3, 0.5, 0.9, 0.0);
}
`;
const gl = this._renderer.GL;
const pg = createShaderProgram(gl, {
vs:vs_drawF, fs:fs_drawF
});
// 頂点の位置をuniformで決める。いずれアトリビュートでもできるようになる。
const positions = new Float32Array(48);
for(let y=0; y<6; y++){
for(let x=0; x<4; x++){
positions[2*(x+y*4)] = x;
positions[2*(x+y*4)+1] = y;
}
}
const pBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pBuf);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// 今回は位置と回転をいじる
const pOffsets = new Float32Array(2000);
const rOffsets = new Float32Array(4000);
for(let i=0; i<1000; i++){
pOffsets[2*i] = random(-1,1);
pOffsets[2*i+1] = random(-1,1);
const angle = random(TAU);
const c = cos(angle);
const s = sin(angle);
rOffsets[4*i] = c;
rOffsets[4*i+1] = -s;
rOffsets[4*i+2] = s;
rOffsets[4*i+3] = c;
}
const pOffsetBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pOffsetBuf);
gl.bufferData(gl.ARRAY_BUFFER, pOffsets, gl.STATIC_DRAW);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
const rOffsetBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, rOffsetBuf);
gl.bufferData(gl.ARRAY_BUFFER, rOffsets, gl.STATIC_DRAW);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 16, 0);
gl.vertexAttribPointer(3, 2, gl.FLOAT, false, 16, 8);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// インデックスの配列。これとTRIANGLESを組み合わせる。
const indexArray = [
// F. 54 indices
0,1,4, 4,1,5, 4,5,8, 8,5,9, 8,9,12, 12,9,13,
9,10,13, 13,10,14, 10,11,14, 14,11,15, 12,13,16, 16,13,17,
16,17,20, 20,17,21, 17,18,21, 21,18,22, 18,19,22, 22,19,23,
];
// この時点ではなんにでも使える
const iBuf = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, iBuf);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint8Array(indexArray), gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
for(let i=0; i<4; i++){
gl.enableVertexAttribArray(i);
if(i>0){
gl.vertexAttribDivisor(i, 1);
}
}
gl.useProgram(pg);
gl.clearColor(0.2, 0.2, 0.2, 1);
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
// 使うときにバインドするとそれが使われる仕組み
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, iBuf);
// 配列はUNSIGNED_BYTEで登録したので、
// ドローコールもUNSIGNED_BYTEで実行します。
// まだ教えてないBLENDを使います
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.drawElementsInstanced(gl.TRIANGLES, 54, gl.UNSIGNED_BYTE, 0, 1000);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
gl.flush();
}
function createShaderProgram(gl, params = {}){
/* 以下略 */
行列アトリビュートなので2と3を占有しています。ベクトルにはちゃんと右から掛けています。glslの行列の扱いには慣れたでしょうか。まあみっちりやったんでね...あとは、特に難しい所はないですね。最後に0,1,2,3をenableにして1,2,3をdivisor:1に設定しておわりです。なお当然ですが、行列は2と3を両方占有するので、2と3の両方にdivisorを設定する必要が...まあ当然か。そういうわけです。あとブレンドは...結局こういうことをしないと、いつまでも単色では味気ないので...気になる人は調べてみてください。
インスタンス描画とdrawArrays, drawElements
divisorは常に存在するので、実は描画の基本はインスタンシングの方にあります。もっともいきなりあれこれ説明するわけにはいかないので、ArraysやElementsから教えざるを得ないわけですが...たとえばアトリビュートがなくてもインスタンシングは可能で、gl_InstanceIDとgl_VertexIDで三角形を量産したりできます。
divisorが1以上のアトリビュートがある状態でdrawArraysやdrawElementsを実行した場合、それらはdrawArraysInstancedやdrawElementsInstancedでインスタンス数が1の場合と同じ挙動になります。つまり、divisorが1以上のアトリビュートに関しては0フェッチの値が統一して使われることになります。インスタンス描画が一般的な場合で、その特殊ケースという位置付けになるわけですね(というか、webGL以外の描画機構ではそもそもインスタンス描画を前提としてドローコールを実行する環境もあるようです。Vulkanがちらっと見たらそんな感じでした)。
たとえば最初の頃のdrawArraysを使った通常描画でもgl_InstanceIDは普通に使えるわけです...もちろん常に0なので役に立たないですが。
おわりに
今回は簡単な内容でしたが、簡単に大量描画ができる仕組みはすごいです。仕組みが凄い、それがインスタンシングです。
ここまでお読みいただいてありがとうございました。
余談:インスタンシングは速い?
メモリの節約がインスタンシングの真骨頂と書きました。では速さは?わかんないです。ベンチマークでも取ってみたらいいと思いますが、そもそも比較対象が謎です。ドローコールの回数が多ければ当然遅くなりますが、全部まとめて一つのジオメトリにした場合はちょっとわかんないです。ただまあそれをやる手間を考えると、自分としてはインスタンシングでやりたいですね...あまりにも面倒なので。それにインデックスバッファの型の問題もあります。インスタンス描画と同じことをインスタンスの描画機構無しでやりたいかと言われれば、遠慮せざるを得ないですね。
追記:はみ出しによる機種依存の挙動はインスタンシングでも起きる
インスタンシングではdivisorという概念があるため、バッファは必ずしもインスタンスの数だけ必要とは限らないです。たとえば一つ当たり4バイトだとして100個描画するなら、divisorが1なら400バイト必要ですが、divisorが10なら40バイトで足ります。その際にdivisorが手違いで1とかにされたりすると、データが足りず「はみ出し」が起きます。その際の挙動は、自分の富士通のノートパソコンなら真っ黒で埋められたりしますが、自分のスマホはそんな横着をしません。全消しです。そういうわけなので、その辺りの管理をきちんとしないといけないわけです。
インスタンシング、以前は複雑だと思ってたんですが、要はdivisorの違いでフェッチの仕方を変えてるだけなんですね...基礎を学ぶのは楽しいです。