LoginSignup
213
202

More than 1 year has passed since last update.

GPU本来の性能を引き出すWebGL頂点データ作成法

Last updated at Posted at 2015-12-08

この記事はWebGL Advent Calendar 2015の9日目の記事です。

ご注意

本記事は2015年当時に書いた記事なのですが、GPUがGeForce 8x00シリーズ以降、SIMDからSIMTという並列実行形式に切り替わった頃から状況が大きく変わりました。
以前は本記事でも紹介するインターリーブ形式の頂点データの方が高速だったのですが、現在のGPUでは多くの環境で非インターリーブの方が高速とされています。
AMD GPUにおいても、GCNアーキテクチャ(PS4以降の世代)から(それまでのVLIWから)SIMTに切り替わり、非インターリーブを推奨されているようです。
WebGLは基本的にネイティブ3D APIへのマッピングに過ぎないため、この傾向はおそらくWebGLでも同様と考えられます。

とはいえ、インターリーブ(AoS)や非インターリーブ(SoA)はCGをやる上でいずれ避けて通れない分野のため、この記事は残しておきます。
カオスな記事になってきたため、いずれ新しい記事でちゃんとまとめ直しをしたいと思います。

はじめに

本記事では、ポリゴンを描画するにあたって、GPU本来の性能を引き出すために最低限必要な、
以下のことについてお話しします。

  • ポリゴンを表示する上で非常に大事な「Vertex Buffer Object」(VBO:頂点バッファオブジェクト)の構築テクニック
  • インデックスバッファ
  • プリミティブの種類

(その他、上記3つとはあまり関係のない余談もたくさんお話しします)

Vertex Buffer Object とはそもそも何なのか

バッファとつく位ですから、何らかのメモリ領域なのだろうことは想像がつきます。しかし、どこにあるバッファなの?
と思った方はいませんか? これはCPU側ではなく、GPU側のメモリにあるバッファなのです。
どうしてGPU側にあると良いのでしょうか?

順を追って説明しましょう。ちょっとここから、昔話をします(が、ほぼt-potさんのこの記事の焼き直しです。知ってる、という方は読み飛ばしてください。)

OpenGL時代の即時モード(glBegin~glEnd)

まず、WebGLの元となったOpenGLの時代。こんなポリゴンの表示方法がありました。
(t-potさんのサンプルコードをほぼそのまま流用させていただきました、すみませんm_ _m)

    glBegin( GL_TRIANGLES ); // プリミティブの種類を三角形にして、描画コマンド作成開始

    // 1つ目の頂点の設定
    glNormal3f  ( 0.0, 1.0, 0.0); // 法線ベクトルの設定
    glTexCoord2f( 0.0, 0.0 ); // テクスチャ座標の設定
    glVertex3f  ( 1.0, 0.0, 0.0); // 位置座標の設定
    // 2つ目の頂点の設定
    glNormal3f  ( 0.0, 1.0, 0.0);
    glTexCoord2f( 1.0, 0.0 );
    glVertex3f  ( 0.0, 0.0,-1.0);
    // 3つ目の頂点の設定
    glNormal3f  ( 0.0, 1.0, 0.0);
    glTexCoord2f( 0.0, 1.0 );
    glVertex3f  (-1.0, 0.0, 0.0);

    // 2つ以上のポリゴンを描画するときは、ここにコマンドを追加

    glEnd();              // 描画コマンド作成終了

 ご覧の通り、もう、各頂点の属性値(位置座標、法線、テクスチャ座標)ごとに、ベクトルを一つ一つ指定していくわけです。(glEnd()で「描画コマンド作成終了」と書いてあるところに注意。glEnd()の時点では描画コマンドが完成しただけであって、それがGPUに送られて実行され、描画が完了したという保証はまだありません。この点については後述します。)

 見た感じ、処理が遅そうだな。というのはなんとなくわかりますね? わざわざベクトル1つ単位で関数をいちいち呼び出していますから、それだけで関数呼び出しコストはありますし、おそらくドライバ内部ではデータの最適化作業などもやっているでしょうから、なおさらCPU時間を消費するはずです。で、ポリゴンの情報はコードの通り、CPU側にあるわけです。
それを、毎フレームGPU側に送るわけです。関数呼び出しコスト&内部最適化処理時間付きで。
 もう、実際めちゃくちゃ遅いんですこれ。
 WebGL及び、そのベースとなった組み込み機器向けのOpenGLサブセット OpenGL ESでは、この機能が真っ先に削られたんですが、その理由もわかろうというものです。CPUの負担が半端ないですからね。強いて言えば、強力なCPUを積んでいてC/C++などの高速なネイティブ言語が動くパソコンOSのネイティブ環境だからこそ使えるアプローチです。

 一方で、プログラムを組むのは非常に楽ですよね。「あ、こういう頂点作りたいんだけど」って思った時点・場所でglVertex3fとか書けばいいわけですから。
 スピードを犠牲にして利便性を取るか……。時代の趨勢をみると、廃れたことから、やっぱり犠牲にしたモノがでかすぎたんじゃないかと…。
 
 一応、この即時モードを事前計算させることで、高速化しようという「ディスプレイ・リスト」という機能もOpenGLにはあったのですが、現在のOpenGLではすでにサポートされていないことをみると、やはりドライバの負担が大きすぎたんでしょうかねぇ。VBOという素直なソリューションが出てきたんで、そっちに道を譲って引退された、というところでしょう。

頂点配列

 で、glBegin~glEndによる即時モードがいろいろ非効率というのは、OpenGL(というかOpenGLの元となったIrisGLの時点でかな?)を作った人たちもわかっていて、頂点配列という機能も用意されていました。
 
 これは、現在のVBOと非常に考え方が近いです。即時モードのように、関数呼び出しでいちいちベクトルを指定するのではなく、表示したいメッシュのすべての頂点データを、CとかC++などのプログラム言語の配列に一気に格納し、glDrawArraysなどの専用の関数にその配列を渡す、というものです。

void render( )
{
    // 配列に頂点データを準備する
    float _pPosition[] = { ... };
    float _pNormal[] = { ... };
    float _pTexCoord[] = { ... };

    // データ配列を有効にする
    glEnableClientState( GL_VERTEX_ARRAY );
    glEnableClientState( GL_NORMAL_ARRAY );
    glEnableClientState( GL_TEXTURE_COORD_ARRAY );

    // 頂点配列の先頭番地として、配列の先頭アドレスを渡す。
    {
        glVertexPointer( 3, GL_FLOAT, 0, _pPosition );
        glNormalPointer( GL_FLOAT, 0, _pNormal );
        glTexCoordPointer( 2, GL_FLOAT, 0, _pTexCoord );
    }

    // 描画命令発行
    glDrawArrays( GL_TRIANGLES, 0, _nVertex );

    // データ配列を無効にする
    glDisableClientState( GL_VERTEX_ARRAY );
    glDisableClientState( GL_NORMAL_ARRAY );
    glDisableClientState( GL_TEXTURE_COORD_ARRAY );
}

(再度、t-potさんのサンプルコードをほぼそのまま流用させていただきました、すみませんm_ _m)
 
 これなら、即時モードに比べて結構性能が改善されます(glVertex系関数呼び出しのコストと、ドライバ内の頂点データ最適化処理が省けるので)。しかも、この配列に入っている頂点データは初めからメモリ的に連続していますから、データがメモリ上に連続して並んでいることを前提としているGPUにとっても相性が良いのです。
 
 ただ、この頂点配列にも欠点はあります。

  • プログラミング難度が少し上がる。 多少腕に覚えのあるプログラマにとってはなんということはないのですが、初心者や仕事で急に覚えなければならなくなった、という人にとっては、即時モードほど直感的ではないので、苦労するかも知れません。
  • 最速の方法ではない。全頂点データはCPU側にあり、そのデータをすべてGPU側に毎回、転送する処理コストがかかります。
  • 配列に、どのようなフォーマットで頂点データを格納するかによって、パフォーマンスが左右される(これは欠点というか、まぁそういうものなのです。詳しくは後述します)。

 とまぁこういう部分はありつつも、なんだかんだ言って、連続したメモリ領域に、自分が望む頂点フォーマットで頂点データを詰め込み、それを描画APIに渡す、というのは3D APIでは最も基本的なお作法です。
 この考え方・やり方に慣れるのは、初めての方は大変かもしれません。しかし、本記事はそういう慣れない初学者のための記事ですので、どうぞ安心してください。
 
 ちなみに、この頂点配列による描画、OpenGL ES 1.x/2.0とかではサポートされていたかと思いますが、WebGLではサポートされていません。やはりより高速なVBOを使ってほしい、ということなんでしょう。
 それでも、「連続したメモリ領域に、自分が望む頂点フォーマットで頂点データを詰め込み、それを描画APIに渡す」という意味での考え方は、この後説明するVBOでも同じです。
  

頂点バッファオブジェクト(VBO)

 さて、長い前置きを経て、ようやくVBOです。VBOとは、その名のごとく、GPU上にバッファ領域を確保し、そこにgl.bufferDataであらかじめ頂点データを送り込んで、CPU側ではそれをVBO(オブジェクト)として管理します(データそのものでなく、ポインタを持っているようなイメージです)。1

 そして、それが済んだら、それ以降はCPU側からは実質的なデータは送らず(だって頂点データはすでにGPU上にありますからね)、ただ、「あなた(GPU)のバッファ上にある、このVBO(頂点データ)を描画してね」という指令を出すだけです。

 CPU->GPUへの頂点データの転送処理がない分、前述の頂点配列による描画よりももっと高速です。なので、WebGLではVBOを使うのが前提となっているわけですね。
 
 さて、なんですが。ただVBOを使えば最速のパフォーマンスを発揮できるのか、というと、実はそう簡単にはいかなかったりします。使い方が重要なんです。

 頂点バッファオブジェクト(VBO)については、t-potさんや床井研究室さん、Project Asuraさんといった、有名どころの方々がサンプルを載せてくれています。
 
が、t-potさんの例では、頂点座標、法線、テクスチャ座標をそれぞれ別個のVBOとして用意しているため、t-potさんも文中で書かれていますが、GPUのデータキャッシュのヒット率が非常に悪くなり、パフォーマンスが出ません。また、インデックスバッファを使っていないというところも非効率と言えるでしょう。

また、床井研究室さんのところでは、ページの一番下のところで、頂点座標、法線、テクスチャ座標のデータを一つのVBOに格納したサンプルを示されていますが、
sNdbj24leq8Q_SYnXCGTtMw.png

という感じで、頂点座標、法線、テクスチャ座標それぞれのデータが離れているため、これもGPUのキャッシュのヒット率が悪く、パフォーマンスがよくありません。

Project Asuraさんの方は、glInterleavedArraysという、決まったパターンの中から頂点データフォーマットを選ぶという方式(パターンにないフォーマットは使えないという欠点があります)のgl命令を使っており、考え方自体は正しいものの、残念ながらこのglInterleavedArraysはWebGLにはありません。

 これらの記事が書かれたのはVBOが登場した当初で、今からすると相当前だったので、致し方ない面はあるんですけどね。
 驚くことに、きちんとパフォーマンスが出る、最低限の最適化を行ったVBOの使い方について、網羅的に書かれた入門記事が、国内では今になってもほとんど見つかりません(いや、あるよ。と言う方は是非ご指摘ください^^;)。 (2016/04/01追記: @doxas さんのwgld.orgの記事が出たので、今後はこのテクニックがどんどん広まることに期待しています!)
 そこで、私が10年ほど前に、こんな記事を書きました。本記事は、それをWebGL向けに再編集したものになります。
 これはゲーム業界などでは、ごくごく当たり前に行われていることだと思うのですが、なぜかネット上になかなか明文化された記事がなく、Web上では割と知られていない知識のような気がしています。
 
 今から本題に入りますが、これはある意味基礎中の基礎です。なんですが、逆に言えばこの基礎を学べるという意味では、本記事はおそらく今後も様々なところから参照され続ける可能性があるし、むしろ是非そうしていただいて、OpenGL/WebGLのパフォーマンスアップに役立てていただきたいと思います。

GPUと親和性の高いインターリーブ配列とは?

 「インターリーブ」と言う言葉を聞かれたことはありますか? 元々は計算機科学と電気通信の領域で使われている用語のようで、領域や問題によって、微妙に意味が違ったりするんですが、少なくともCGの頂点データフォーマットにおける意味としては、
 「位置座標、カラー、法線、テクスチャ座標などのデータが、別個の配列として独立しているのでは『なく』、同一配列内に、各頂点分ごとに、並んで敷き詰められているデータ形式」
 のことです。

 うーん、言葉で説明するのって難しいですね。図にするとこんな感じです。
 
 s1Czxc_69NY4TEDm4i6gyaA.png

 なんでこういうデータ配置の方が良いかというと、GPUさんの都合を考えるとよくわかります。
 
 GPUは頂点シェーダーで使う頂点データを取り出すとき、まぁ一つ一つ頂点を取り出していくわけですな。そのときに、もしこのデータの並びになっておらず、VBOデータ内で、ある頂点の位置座標、法線、テクスチャ座標などのベクトルデータが、それぞれ離れたメモリ番地に存在していたらどうでしょう。あるいは、それらが全く別の複数のVBOに分かれて点在していたとしたらどうでしょう?
 いちいち、一生懸命離れた場所にあるそれらのデータを、必死に探してかき集めなきゃならんわけですよ。たかだか一つの頂点のデータを得るために、です。そんな面倒くさいことを、100万頂点あったら、100万回やらにゃならんわけです。そりゃスピードも落ちますよね。しかも、こういうケースは頂点シェーダーの前段にあるPre T&L キャッシュ(VBOのデータをキャッシュしてくれるものです)のヒット率が大幅に下がる可能性があります。だからもうなおさら遅くなるわけです。

 でも、頂点フォーマットをこのインターリーブ配列にしておけば!
 各頂点を構成する位置座標、法線、テクスチャ座標などのデータは、メモリ上連続した位置に密にきっちり詰まっているわけです。となると、GPUはこの連続した領域から一度に頂点のデータを取ることができるわけですね(一般的に、メモリアクセスはバラバラの場所にランダムアクセスするより、連続した領域をごっそりアクセスするシーケンシャルアクセスの方がはるかに高速です)。しかも、Pre T&Lキャッシュもヒットしやすくなります。
 もう良いことずくめですね。っていうか、現在のGPUは元々こういう形式を前提として最大のパフォーマンスを発揮するように設計されているので、これがある意味普通のあるべき姿です^^;
 
 さて、では具体的なコーディングに入りましょう。 
 頂点座標、法線、テクスチャ座標といった、頂点座標およびその頂点の属性は、メモリ的に隣接して格納されていなければいけません。CPU側のコードで言えば、こんなイメージですね。↓

  function MY_VECTOR3(x, y, z) {
    this.x = x;
    this.y = y;
    this.z = z;
  }
  function MY_VECTOR2(x, y) {
    this.x = x;
    this.y = y;
  }
  function MY_VERTEX(){
    this.vPos = new MY_VECTOR3(0, 0, 0);
    this.vNorm = new MY_VECTOR3(0, 0, 0);
    this.vTex = new MY_VECTOR2(0, 0);
  }

(注:JavaScriptに詳しい方ならお気づきかと思いますが、このコードだと実際には「メモリ的に隣接して」 ません。もともと本記事の元になったこの記事では、C言語をベースに構造体として定義して説明していたので、上の説明はその名残です。ただ、概念としては分かりやすいと思うので、あくまでイメージとして捉えてください。後に例示するサンプルコードでは、新規のJavaScript配列にメモリ的に連続したデータとして、データを入れ直す処理を入れています。)
(上記の「JavaScript配列にメモリ的に連続したデータとして」はよく調べたら間違いでした。そもそもJavaScript配列自体が、メモリ的に連続している保証はどこにもありません(ただし、上記のデータ入れ直しによって、要素の並び順としてシーケンシャルにはなります)。本当にメモリ的に連続になるのは、このシーケンシャルな配列をFloat32ArrayなどのTyped Arrayに変換した時です。)

MY_VERTEXのような構造体を利用することによって、頂点座標、法線、テクスチャ座標といった頂点データが、メモリ上で隣り合って存在するようになります(あくまでイメージです!^^; JavaScriptの場合、実際は違います)。まさにこんな図の形になるわけですね。

s1Czxc_69NY4TEDm4i6gyaA.png

上記のMY_VERTEXの頂点フォーマット仕様を元に、頂点データを作成する関数の例を以下に示します。

  function initShader() {
    ...(省略)...
    // この gl.enableVertexAttribArray の呼び出しは、各シェーダーの初期化時に一度だけ呼び出せば大丈夫です。描画の度に実行する必要はありません。
    g_shaderProgram.vertexPositionAttribute = gl.getAttribLocation(g_shaderProgram, "aVertexPosition");
    gl.enableVertexAttribArray(g_shaderProgram.vertexPositionAttribute);
    g_shaderProgram.vertexTexcoordAttribute = gl.getAttribLocation(g_shaderProgram, "aTexcoord");
    gl.enableVertexAttribArray(g_shaderProgram.vertexTexcoordAttribute);
    g_shaderProgram.vertexNormalAttribute = gl.getAttribLocation(g_shaderProgram, "aNormal");
    gl.enableVertexAttribArray(g_shaderProgram.vertexNormalAttribute);
    ...(省略)...
  }

  function createVerticesData() {
    // 最初はわかりやすいように、ベクトルクラス、バーテックスクラスに値を格納していく。
    // JavaScriptの性質上、この時点でデータはメモリ的に連続ではない。
    var halfLength = 0.5;
    var verticesN = g_faceN * 3;

    var vertexArray = new Array( verticesN );
    for(var i = 0; i<verticesN; i++) {
      vertexArray[i] = new MY_VERTEX();
    }

    // 頂点データの作成。今回は簡単な例として単純な四角形を用意
    // インデックスバッファを使わない今回の例では、
    // 頂点データの並びは、GPUに処理させたい順番で定義する必要があります。
    vertexArray[0].vPos = new MY_VECTOR3(-halfLength, -halfLength, 0);
    vertexArray[1].vPos = new MY_VECTOR3(halfLength,  -halfLength, 0);
    vertexArray[2].vPos = new MY_VECTOR3(-halfLength, halfLength ,0);
    vertexArray[3].vPos = new MY_VECTOR3(halfLength, halfLength ,0);

    vertexArray[0].vNorm = new MY_VECTOR3(0, 0, 1);
    vertexArray[1].vNorm = new MY_VECTOR3(0, 0, 1);
    vertexArray[2].vNorm = new MY_VECTOR3(0, 0, 1);
    vertexArray[3].vNorm = new MY_VECTOR3(0, 0, 1);

    vertexArray[0].vTex = new MY_VECTOR2(0, 0);
    vertexArray[1].vTex = new MY_VECTOR2(1, 0);
    vertexArray[2].vTex = new MY_VECTOR2(0, 1);
    vertexArray[3].vTex = new MY_VECTOR2(1, 1);


    //データがシーケンシャルな並びになるよう、コピーしていく。
    var stride = 3+3+2;
    var vertexSequencialArray = new Array(g_faceN*3*(stride));
    for(var i = 0; i < verticesN; i++) {
      vertexSequencialArray[i*stride+0] = vertexArray[i].vPos.x;
      vertexSequencialArray[i*stride+1] = vertexArray[i].vPos.y;
      vertexSequencialArray[i*stride+2] = vertexArray[i].vPos.z;
      vertexSequencialArray[i*stride+3] = vertexArray[i].vNorm.x;
      vertexSequencialArray[i*stride+4] = vertexArray[i].vNorm.y;
      vertexSequencialArray[i*stride+5] = vertexArray[i].vNorm.z;
      vertexSequencialArray[i*stride+6] = vertexArray[i].vTex.x;
      vertexSequencialArray[i*stride+7] = vertexArray[i].vTex.y;
    }

    // VBO作成。シーケンシャルなJS配列をFloat32Arrayにすることによって、メモリ的に連続したネイティブなfloat配列にする
    g_vbo = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, g_vbo);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexSequencialArray), gl.STATIC_DRAW);
    gl.bindBuffer(gl.ARRAY_BUFFER, null);

  }

  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    gl.bindBuffer(gl.ARRAY_BUFFER, g_vbo);

    // 頂点レイアウトを規定し、第一引数で指定した頂点属性インデックスに現在バインド中のVBOを紐付ける。
    var byteStride = 4*(3+3+2);
    gl.vertexAttribPointer(g_shaderProgram.vertexPositionAttribute, 3, gl.FLOAT, false, byteStride, 0);
    gl.vertexAttribPointer(g_shaderProgram.vertexNormalAttribute, 3, gl.FLOAT, false, byteStride, 4*3);
    gl.vertexAttribPointer(g_shaderProgram.vertexTexcoordAttribute, 2, gl.FLOAT, false, byteStride, 4*(3+3));

    // ドローコール発行
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, g_faceN + 2);
    gl.bindBuffer(gl.ARRAY_BUFFER, null);
  }

コメントで「頂点レイアウトを規定し〜」としているところがポイントです。ストライドとオフセットを設定しています。
gl.vertexAttribPointer第6引数にはオフセットを指定します。
このオフセットはどこからのオフセットかというと、頂点バッファg_vboの先頭アドレスからのオフセットです。もう一度、我々が想定している頂点データの構造を再確認してみましょう。

s1Czxc_69NY4TEDm4i6gyaA.png

  function MY_VERTEX(){
    this.vPos = new MY_VECTOR3(0, 0, 0);
    this.vNorm = new MY_VECTOR3(0, 0, 0);
    this.vTex = new MY_VECTOR2(0, 0);
  }

でしたね。
頂点座標this.vPosの場合は、一番最初にあるので、gl.vertexAttribPointerに設定するオフセットは0です。
法線の場合は、this.vNormを見てみると、this.vPos = new MY_VECTOR3(0, 0, 0);の直後であり、また、前提としてgl.vertexAttribPointerの第3引数にgl.FLOATとあるように、ベクトルの1要素のサイズはfloat(4バイト)なので、法線用にgl.vertexAttribPointerで設定するオフセットは4(byte)*3(要素)=12(byte)です。
テクスチャ座標this.vTexの場合は、this.vNorm = new MY_VECTOR3(0, 0, 0);の直後なので、gl.vertexAttribPointerで設定するオフセットは4(byte)*(3+3)(要素)=24(byte)です。

次にストライドですが、ストライドとは次の頂点のデータ位置までの距離のことです。
gl.vertexAttribPointerの第5引数でストライドを指定します。
例えば、頂点座標で考えてみると、最初の頂点座標から、次の頂点座標までのメモリ上の距離はMY_VERTEX

  function MY_VERTEX(){
    this.vPos = new MY_VECTOR3(0, 0, 0);
    this.vNorm = new MY_VECTOR3(0, 0, 0);
    this.vTex = new MY_VECTOR2(0, 0);
  }

のサイズ、つまり4(byte)*(3+3+2)(要素)=32(byte)です。
法線やテクスチャ座標も、最初のデータから次のデータまでの距離は32byteです。
つまり、頂点座標、法線、テクスチャ座標それぞれについてのgl.vertexAttribPointer呼び出しに関して、設定すべきストライドは全て同じ値(今回の例では32byte)でよいことになります。簡単ですね。

sVJu63_WUP5EfUTgpqBDQog.png

補足1:頂点座標、法線、テクスチャ座標それぞれを以下のようにメモリ上で別個のつながりとして格納している場合

 頂点座標、法線、テクスチャ座標それぞれを以下のようにメモリ上で別個のつながりとして格納している場合は、設定するストライド値は0になります。
 頂点座標、法線の場合は4(byte)*(3)(要素)=12(byte)、テクスチャ座標の場合は4(byte)*(2)(要素)=8(byte)とかを指定してもいいんですが、0を指定すると次のデータがすぐ隣に隣接していることを示す、という関数仕様になっているので、ここでは0を指定します。

sNHor_qRiJQzVDQJpsJ9-mA.png

 ただし、前述の通り、このデータ配置ではインターリーブになっていないので、各頂点データを取り出すのにランダムアクセスが発生し、パフォーマンスが低下するのはすでに申し上げた通りです。

補足2:gl.enableVertexAttribArray は描画の度に呼び出す必要はない

 gl.vertexAttribPointerの前に呼び出すことになるgl.enableVertexAttribArrayですが、たまにgl.vertexAttribPointerの呼び出しと合わせて、「描画の度に呼び出している」人がいますが、その必要はありません。
 gl.enableVertexAttribArraygl.disableVertexAttribArrayの呼び出し箇所をどうするかの考え方は、だいたい以下の方針に従えば大丈夫です。
 
単一種類のメッシュのみ描画する → シェーダー初期化時にgl.enableVertexAttribArrayを必要attribute数分、一度だけ呼べばOKです。
複数種類のメッシュを描画するが頂点シェーダーで必要となるattribute変数の数は同じ → 同上。シェーダー群初期化時にgl.enableVertexAttribArrayを必要attribute数分、一度だけ呼べばOKです。(『各』シェーダー初期化ごとに重ねて呼ぶ必要はありません)。
複数種類のメッシュを描画する。それらの間で必要となるattribute変数の数は異なる → 描画するメッシュの種類を切り替える際に、必要に応じてgl.enableVertexAttribArraygl.disableVertexAttribArrayの呼び出しをする必要があります。詳しくは以下の記事をご覧ください。呼び出し回数をできるだけ減らせるように、同一種類のメッシュインスタンスはまとめて描画すると良いでしょう。
 参考:『WebGLで複数のシェーダー使用時にハマる罠「俺のVBOがアタッチされてないわけがない!」
 
 gl.enableVertexAttribArrayは、OpenGL実装にもよりますが、一般的にかなり高コストなGL命令ですので、もし描画毎に呼び出していたら、これを削減するだけで結構な速度改善が期待できます。
 

補足3:環境によっては、頂点データのアライメントを揃えるとさらに速くなる

 どういうことかと言いますと、WEBGLの実装って色々ありまして、ネイティブのOpenGL ESがバックエンドになっている環境もあれば、Windows版のChromeなどのように、ANGLEという中間層が内部的にDirect3Dにトランスレートしている環境もあります。
 で、多くの環境では、4バイト(32bit)境界ごとに頂点データが詰まっていないと、WebGL(に限らず、他のOpenGL環境でも起こりえますが)ドライバが内部的に、アライメントを揃えるために頂点データにダミーのバイトを内挿する処理を入れる場合があります。
 
 例えば、頂点カラーなどはRGBの3チャンネル、フルカラーの場合でもそれぞれ1バイトで済むため、C言語風に言えば、floatでなく、unsingned byteで済ませられます。
 (具体的には、gl.bufferDataでの頂点データ設定時に、Float32Arrayではなく、Uint8Arrayを使い、さらにgl.vertexAttribPointerでの型指定でgl.FLOATでなくgl.UNSINGED_BYTEを指定します)
 ただ、この時にストライドを4の倍数にしていないと、上記の現象が発生してしまうのですね。
 
 この動作、VBOを一度作成したらもう変更しない場合は、その初期化時のみにかかるコストなのであまり問題にはならないのですが、VBOを毎フレーム更新するような使い方をしている場合は、毎回発生してしまうコストになります。

 解決方法としてはいろいろありますが例えば、バッファとしては4の倍数を考慮したサイズを確保して、R、G、Bの次のバイトには、値を設定しないか、その他シェーダーで必要そうな情報があれば、そのデータを入れておく、(そして、ストライドを4の倍数にする)というものが考えられます。
 
 ちなみに、float値(4バイト)3チャンネル(合計12バイト)の頂点データの場合はどうか。4チャンネルにして、16バイト境界にした方が速いんじゃないの? という疑問を持たれる方もいらっしゃるかもしれません。どうでしょう? もしかしたら、その方がなお速くなる環境もあるかもしれませんね。もし無理なくできるならそうしてください。ただ、少なくとも前述のように、ダミーの4バイトデータが勝手に内挿される環境はそうそうないと思われます。あまりそこまで神経質にならなくてもいいでしょう。
 
 ただ、例えば頂点位置x,y,z(wはなし)と、頂点カラーRGBαを送る場合であれば、頂点位置はfloat3チャンネル(12バイト)、頂点カラーはunsigned byte4チャンネル(4バイト)にすれば、両方合わせて16バイトですから、ちょうどよく、4の倍数どころか16バイトアライメントぴったりの頂点データにできて非常に美味しいですね♪
 

まだだ、まだ終わってなぁい!

 さてさて、単体VBOで頂点フォーマットをインターリーブにしたし。「もう俺たちGPUパフォーマンスを最高に引き出したよな!」
 甘い! まだです。キミたちはまだインデックスバッファを使ってなぁーい!

さらに、インデックスバッファを使おう!

 さて、インデックスバッファとはなんでしょうか。
 
 まず、インデックスバッファの「インデックス」とは、定義済みの頂点データの配列に対して、その配列の添字(インデックス)を指定することで「どの頂点を指定しているか」ということを示す情報です。
 インデックスバッファとは、その添字情報を収めたバッファのことです。
 
 インデックスバッファを使うと、以下の利点があります。

  • 頂点データの定義を、処理したい順番から定義しなくても良くなる
  • 頂点データの定義で、重複する部分がなくなるので、VBOのデータが削減される(結果、Pre T&Lキャッシュのヒット率も向上)
  • さらに、Post T&Lキャッシュも効くようになる。

 Post T&Lキャッシュとは、頂点シェーダーの後段に設置されているキャッシュメモリです。Pre T&L キャッシュよりかなりメモリサイズが小さいのですが、頂点シェーダを通過した頂点からプリミティブ(ポリゴン)を組み立てる処理を高速化してくれる、とても重要なキャッシュメモリです。

Kobito.r4euFO.png
 
 インデックスバッファを使うことで、インデックスで指定したどの頂点とどの頂点が同じ頂点である、という情報がすぐに分かるため、プリミティブの組み立てが非常に楽になるんでしょう。その理由で、Post T&L キャッシュもヒットしやすくなります。
 というか、インデックスバッファを使わないと、このPost T&Lキャッシュが全くと言っていいほど効かなくなります。なんということでしょう。となれば、絶対使うしかないですよね。
 
 インデックスバッファも使った頂点データの作成コード例を以下に示します。

  function initShader() {
    ...(省略)...
    g_shaderProgram.vertexPositionAttribute = gl.getAttribLocation(g_shaderProgram, "aVertexPosition");
    gl.enableVertexAttribArray(g_shaderProgram.vertexPositionAttribute);
    g_shaderProgram.vertexTexcoordAttribute = gl.getAttribLocation(g_shaderProgram, "aTexcoord");
    gl.enableVertexAttribArray(g_shaderProgram.vertexTexcoordAttribute);
    g_shaderProgram.vertexNormalAttribute = gl.getAttribLocation(g_shaderProgram, "aNormal");
    gl.enableVertexAttribArray(g_shaderProgram.vertexNormalAttribute);
    ...(省略)...
  }

  function createVerticesData() {
    // 最初はわかりやすいように、ベクトルクラス、バーテックスクラスに値を格納していく。
    // JavaScriptの性質上、この時点でデータはメモリ的に連続ではない。
    var halfLength = 0.5;
    var verticesN = g_faceN * 2;
    var vertexArray = new Array( verticesN );
    for(var i = 0; i<verticesN; i++) {
      vertexArray[i] = new MY_VERTEX();
    }
    vertexArray[0].vPos = new MY_VECTOR3(-halfLength, -halfLength, 0);
    vertexArray[1].vPos = new MY_VECTOR3(halfLength,  -halfLength, 0);
    vertexArray[2].vPos = new MY_VECTOR3(-halfLength, halfLength ,0);
    vertexArray[3].vPos = new MY_VECTOR3(halfLength, halfLength ,0);

    vertexArray[0].vNorm = new MY_VECTOR3(0, 0, 1);
    vertexArray[1].vNorm = new MY_VECTOR3(0, 0, 1);
    vertexArray[2].vNorm = new MY_VECTOR3(0, 0, 1);
    vertexArray[3].vNorm = new MY_VECTOR3(0, 0, 1);

    vertexArray[0].vTex = new MY_VECTOR2(0, 0);
    vertexArray[1].vTex = new MY_VECTOR2(1, 0);
    vertexArray[2].vTex = new MY_VECTOR2(0, 1);
    vertexArray[3].vTex = new MY_VECTOR2(1, 1);

    //データをシーケンシャルな並びにするよう、コピーしていく。
    var stride = 3+3+2;
    var vertexSequencialArray = new Array(g_faceN*3*(stride));
    for(var i = 0; i < verticesN; i++) {
      vertexSequencialArray[i*stride+0] = vertexArray[i].vPos.x;
      vertexSequencialArray[i*stride+1] = vertexArray[i].vPos.y;
      vertexSequencialArray[i*stride+2] = vertexArray[i].vPos.z;
      vertexSequencialArray[i*stride+3] = vertexArray[i].vNorm.x;
      vertexSequencialArray[i*stride+4] = vertexArray[i].vNorm.y;
      vertexSequencialArray[i*stride+5] = vertexArray[i].vNorm.z;
      vertexSequencialArray[i*stride+6] = vertexArray[i].vTex.x;
      vertexSequencialArray[i*stride+7] = vertexArray[i].vTex.y;
    }

    // インデックスの作成
    // 今回、インデックスが綺麗な昇順になっていますが、たまたまです。実際は、状況によって変化します。
    // インデックスがあることで、頂点データを処理させたい順番に定義する必要は無くなります
    //(この例では、たまたま処理させたい順に頂点データを定義してますが…
    // (だからそれに対応する形で、インデックスが綺麗な昇順になっている))
    // ただし、インデックスで順番を指定できるからといって、前のインデックスと次のインデックスの間の
    //(各インデックスが指し示す頂点データ的な意味での)間隔を、
    // Pre T&L キャッシュがカバーする以上に(メモリ上)離れさせないでください。
    // Pre T&L キャッシュのヒット率が落ちてしまいます。
    var indices = new Array(g_faceN + 2);
    indices[0] = 0; 
    indices[1] = 1; 
    indices[2] = 2; 
    indices[3] = 3;

    // VBO作成。シーケンシャルなJS配列をFloat32Arrayにすることで、メモリ的に連続したネイティブなfloat配列にする。
    g_vbo = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, g_vbo);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexSequencialArray), gl.STATIC_DRAW);
    gl.bindBuffer(gl.ARRAY_BUFFER, null);

    // IndexBuffer作成
    g_ibo = gl.createBuffer();
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g_ibo);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);

  }

  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    gl.bindBuffer(gl.ARRAY_BUFFER, g_vbo);

    // 頂点レイアウトを規定し、第一引数で指定した頂点属性インデックスに現在バインド中のVBOを紐付ける。
    var byteStride = 4*(3+3+2);
    gl.vertexAttribPointer(g_shaderProgram.vertexPositionAttribute, 3, gl.FLOAT, false, byteStride, 0);
    gl.vertexAttribPointer(g_shaderProgram.vertexNormalAttribute, 3, gl.FLOAT, false, byteStride, 4*3);
    gl.vertexAttribPointer(g_shaderProgram.vertexTexcoordAttribute, 2, gl.FLOAT, false, byteStride, 4*(3+3));

    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g_ibo);
    // ドローコール発行
    gl.drawElements(gl.TRIANGLE_STRIP, g_faceN + 2, gl.UNSIGNED_SHORT, 0);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
    gl.bindBuffer(gl.ARRAY_BUFFER, null);
  }

余談

 Pre T&L キャッシュとかPost T&Lキャッシュの 「T&L」って何? と思われた方もいらっしゃるかと思います。
 これは、私も100%の確信を持っては言えないんですが、おそらく「Transform & Lighting」の略ではないかと思っています。というのも、大昔のグラフィクスカード(90年代前半?)って、ラスタライズとかピクセル処理はグラフィックスチップが担当していたんですが、頂点処理はCPU側がやっていたんですね。
 それを、NVIDIAがリリースした、初代GeForceである「GeForce256」が、民生向けで初めて、グラフィックスチップ上で頂点処理も行うようになりました(これにより、リアルタイムCGで扱えるポリゴン数が大幅に向上しました)。
 この頃の、この機能の名前が「Hardware T&L」というものでした。「ハードウェア(グラフィックチップ)内で頂点処理(行列変換による頂点のトランスフォームとライティング処理)ができるぜ!」ってことですね。
 おそらく、Pre T&L キャッシュとかPost T&Lキャッシュというキャッシュメモリは、おそらくこの頃からあったのではないかなーと思います。この頃の「Hardware T&L」の機能名の「T&L」が、その名残としてキャッシュメモリの方にも呼び名としてついたのではないかな、と私は想像しています。

 もし、「事実と違うぞゴラァ!」という方がいらっしゃいましたら、ぜひご指摘くださいませ^^;

 ちなみに、NVIDIAがリリースしたGeForce3から、頂点処理・ピクセル処理がプログラムできるようになり(いわゆる今日のプログラマブルシェーダーですね)、NVIDIAが「これからはグラフィックスチップもCPUみたいにプログラマブルになったんだから、GPUと呼ぼうぜ!」と言い出しました。ライバルのAMDは「いや、俺たちはVPUと呼びたいな」と対抗していたんですが、「VPU」ではなく「GPU」という呼称の方が世に定着したのはみなさんご存知の通りです(こういうところがNVIDIA強いんですよね^^;)。
 
 (2016/10/30 追記:ちょっと間違ってました。識者の方々からの情報によると、どうやらGeForce 256の時からすでにNVIDIAはGPUという呼称を使い始めていたようです。)

 あと、頂点シェーダーのシェーダーって、違和感ありません? シェード(陰影付け)って意味ですからね。今や頂点シェーダーでライティング処理とかあんましないし、「頂点シェーダというよりも頂点プログラムと呼ぶべきだ。なんでもシェーダシェーダ言うのイクナイ!」という業界の声もあったんですが、結局是正されず今に至るというw

 CG業界って、面白いですね^^;
 

プリミティブとは

 すみません。余談が長くなりました。
 さて、gl.TRIANGLESとかgl.TRIANGLE_STRIPといった定数は見たことがある、という方も多いと思います。
 これは、「定義された連続した頂点データから、どのようにプリミティブ(三角形ポリゴン)を構築していくか」という決まりのことです。この「決まり」にどのような種類があるかについては、文章で説明するのがめんどいので、床井先生のGLUT入門ページに投げちゃいます(爆)
 
 よく使われるのは、gl.TRIANGLESgl.TRIANGLE_STRIPですが、どちらが早いかというと、昔はgl.TRIANGLE_STRIPだったのですが、最近は逆転してgl.TRIANGLESの方が数%速いとか、なんかそんな話にもなっているようです。
 相互に変換が可能ですが、WebGLでサクッと動かす場合は、無理に変換せずにそのまま読み込んだ頂点データのプリミティブ形式そのままに描画した方が、トータルで応答の良いシステムを作れるでしょう。
 (頂点最適化も重要ですが、それ前にWebGLはボトルネックになり得る部分が結構あります)

相互の変換とか、結構めんどくさいんですけどね。ただ、世の中便利なもので、頂点の最適化を行うツールがあります。
最近だと AMD Compressonatorとかですかね。

 (ちなみに、WebGLではgl.QUADS(四角形)やgl.QUAD_STRIP(四角形ストリップ)、gl.POLYGONS(多角形)はサポートされていません)

 ということで、最終結論としては、「GPUは、インデックスバッファを使い、、インターリーブ配列にした(※ここも最近はそうでなくなっているらしい。本記事コメント参照)VBOを描画することで、最適なパフォーマンスが得られる」ということになります。

補足:gl.TRIANGLE_FANは避けよう

 なぜでしょうか。これはWindows環境だと特にそうです。というのも、Windows版ChromeやFirefoxのWebGL実装であるANGLEは、最近のバージョンだとDirect3D 11をバックエンドにしています。しかしこのDirect3D。バージョン11になって、TRIANGLE_FANのサポートを止めてしまったのです。なので、gl.TRIANGLE_FANを指定すると、それを内部的にTRIANGLE_STRIPなデータにするために、頂点データの再構築処理が入ってしまいます。
 これも、VBOを毎フレーム更新するようなアプリケーションでは大きな問題になります。気をつけてください。

  まぁ、おそらく今後はDirect3Dと言うよりも、今後作られるGPUハードウェア全般として、TRIANGLE_FANよりもTRIANGLE_STRIPに最適化された設計になっていくと思います。とにかく、TRIANGLE_STRIP使っておけば問題ないです。

 (作りたい形状によっては、TRIANGLE_STRIPだと縮退(面積ゼロ)のポリゴンを入れないといけないケースが多々あって、面倒くさいですけどね)

上級編余談その1:Vertex Array Object

 上のコード例で、drawScene関数内で、以下のコード部分がありましたよね。

    // 頂点レイアウトを規定し、第一引数で指定した頂点属性インデックスに現在バインド中のVBOを紐付ける。
    var byteStride = 4*(3+3+2);
    gl.vertexAttribPointer(g_shaderProgram.vertexPositionAttribute, 3, gl.FLOAT, false, byteStride, 0);
    gl.vertexAttribPointer(g_shaderProgram.vertexNormalAttribute, 3, gl.FLOAT, false, byteStride, 4*3);
    gl.vertexAttribPointer(g_shaderProgram.vertexTexcoordAttribute, 2, gl.FLOAT, false, byteStride, 4*(3+3));

 
 これ、毎回描画時にやらないといけないのがめんどいですよね。実行コストも多少かかるし。
 (一種類のメッシュのみを描画するなら、drawScene関数以外の場所で一度だけ実行するのでもいいのですが、複数種類のメッシュを描画したいなら、そうはいきません。)
 
 (たまに勘違いする方がいるのですが、gl.drawArraysなどで頂点の描画をするにあたって、どのVBOのデータを使うか、という指定はgl.bindBuffer(gl.ARRAY_BUFFER, xxx)だけではできません。gl.bindBuffer(gl.ARRAY_BUFFER, xxx)を呼んだ上で、なおかつgl.vertexAttribPointerを実行する必要があります。gl.vertexAttribPointerの意味は、「第一引数で指定した頂点シェーダーの頂点属性インデックスに、現在gl.bindBuffer(gl.ARRAY_BUFFER, xxx)でバインド中のVBOを関連付ける(使用する)という意味です。)
 
 Vertex Array Object(以下、VAO)を使うと、このgl.vertexAttribPointerの設定をVAOに保存させることができ、描画時にはそのVAOをバインドするだけでよくなります。なんと、VBOやインデックスバッファのバインドすら省略できます。というのも、VBOに関して言うと、VAOがgl.vertexAttribPointerの設定を記憶しており、そしてそのgl.vertexAttribPointerの設定が、同関数によって関連付けたVBOを記憶しているからです。
 (あくまで間接的で、VAOが現在バインドされているVBOを直接記憶しているわけではないようです。その一方、OpenGL仕様書を確認したところ、インデックスバッファの方は(VBOと違って)、VAOはどのインデックスバッファをバインドしたのかを直接的に記憶しているようです。ややこしいですね^^;)
 
 (たまに、VAOはインデックスバッファの関連付けまではやってくれない、と勘違いする方がいらっしゃるんですが、ちゃんとやってくれます。もしかしたら、勘違いされている方は以下のようなコードを書いているからかもしれません)

function setupVertexData() {
  gl.bindVertexArray(vao);
  ibo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo );
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null); // ← これが原因
  gl.bindVertexArray(null);
}

 (そうではなく、以下のように書けば大丈夫です)

function setupVertexData() {
  gl.bindVertexArray(vao);
  ibo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo );
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
  // ↓ VAOバインドを解除する前に、インデックスバッファをnullバインドすると、
  //   「インデックスバッファは使わない」という意味でVAOに記録されてしまう
  // gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null); // ← なので、ここはコメントアウトし
  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null); // ← ここに書く
}

 VAOの使い方の詳細、本記事で書こうとも思ったのですが、Google Japanがまさにドンピシャな記事を公開していますので、説明はそちらに譲りますw
 
 ただ、VAOは来たるWebGL2.0か、WebGL1.0の拡張機能でしかサポートされていません。具体的には、2015年12月現在では、ChromeとFirefox、Safariでしか使えない(つまり、IE11とEdgeが…)ので注意してください。

上級編余談その2:ジオメトリインスタンシング

 あと、WebGL2.0ではさらに「ジオメトリインスタンシング」(ANGLE_instanced_arrays)という機能が使えます。
 これは、同様のメッシュを大量にドローコールを出して大量に描こうとしている時、それらの大量のドローコールを一つにまとめることができる機能です。
 同一メッシュ(以後、インスタンスと呼びます)を複数描画する時、それぞれのインスタンスはほとんどの場合、描画する位置が違いますよね? すなわち、頂点シェーダー内で頂点位置座標の変換に使う変換行列(モデルビュープロジェクション行列)がインスタンス毎に違うわけです(まぁ、ビュー行列とプロジェクション行列はカメラ設定が変わらない限り不変なので、より正確には(Direct3D的な用語で)「World行列が毎回異なる」というべきでしょうか)。
 つまり今までの描画方法では、各インスタンスを描画するにあたり、(各インスタンスで値が異なるので)変換行列をその都度その都度、Uniform変数として設定し直して、そして描画命令をインスタンスごとに発行する、ということをやっていたわけです。Uniform変数として変換行列データをGPUに送るコスト、そして描画命令の発行コスト。これらは決して無視できない(というよりかなり大きい)性能上のボトルネックです。
 そこで、ジオメトリインスタンシング(ANGLE_instanced_arrays)です。
 ジオメトリインスタンシングを使うと、このインスタンスごとに存在する変換行列のセットを、まるでVBOのようにGPU上のバッファに前もってあらかじめ一気に転送しておくことで、描画の際に頂点シェーダーがそのバッファから変換行列を取ってくることができます。
 (上記の説明では変換行列のみにフォーカスを当てましたが、実際のジオメトリインスタンシングでは、変換行列だけでなく、カラーなどの他の情報も、描画したいインスタンス分、前もってバッファに送ることができます。つまり、各インスタンスの位置だけでなく色なども個別に指定でき、しかもそれらのインスタンス群を一発で描画できるのです)
 この仕組みにより、一回の描画命令で複数のインスタンスを一度にだだだーっと、(変えたい属性をインスタンスごとに変化させつつ)描画することができるわけです。その間、当然ながらインスタンスごとにUniform変数の設定をいちいち変える必要などありません。もう、これを使えば相当描画が速くなるであろうことが、予想できますね!
 実際、このジオメトリインスタンシングの機能はPS3/Xbox360世代以降のゲーム機/PCゲームでは(もしかしたら、コンソールゲーム機ではもう少し前の世代の機種でもすでにあったかもしれませんが、そこらへん私はよく知りません^^;)ごく当たり前のように使われています。おそらく、この機能がなければ今日のゲームのように、大量のオブジェクトを画面に描画しまくることはできなかったことでしょう。
 
 この機能の具体的な使い方ですが、専門の記事を書きましたのでそちらをどうぞ。
  また、@doxasさんが運営されているWebGL解説サイト「wgld.org」でも、ジオメトリインスタンシングの記事が公開されています。wgld.org素晴らしいですね。いつもお世話になっております。

またまた余談:ドローコールをしても、すぐに描画されるとは限らない

glBegin~glEndの説明で、

(glEnd()で「描画コマンド作成終了」と書いてあるところに注意。glEnd()の時点で描画コマンドが完成しただけであって、それがGPUに送られて実行され、描画が完了したという保証はまだありません。この点については後述します。)

 と書きました。
 OpenGL/WebGLでは、glBegin~glEndやglDrawArrays、glDrawElementsなどのドローコールの呼び出しは非同期実行になります。つまり、「これらの関数を呼んだからと言って、すぐさま描画が行われるとは限らないし、関数から制御が戻ってきた時点で、描画が終わっている保証はない(それどころか、始まっている保証すらない)」ということです(まぁ、これはDirect3Dでもそうですけどね)。

 WebGLをはじめとしたGPUを駆動するリアルタイム3D APIは、プログラマがドローコール関数を呼ぶと、描画命令を「コマンドキュー」と一般に言われるキューに貯めていきます。そう、貯まっていくのであって、キューの中で自分のコマンドがフェッチされて実行されるまでは、「待ち」なわけです。
 3D APIのドライバは、(私もあまりドライバレベルの動作は詳しくないのですが)、ドローコールを始めとした各種コマンドを、一旦CPU側で実行順序の調整・冗長性を省くなどの最適化を行った上で、GPUへ送出していきます。その動作仕様は基本的に、WebGL/OpenGLレベルの比較的「ドライバの厚い」高級3D APIではブラックボックスで、細かい制御ができないのです。

 「え? WebGL/OpenGLが高級APIだって!?」と驚かれた方も中にはいらっしゃるかもしれません。特に、WebGLの扱いで四苦八苦していらっしゃる、初心の方にとってはにわかに信じがたいことでしょう。
 しかし、WebGL/OpenGLより低レベル(ハードウェアに近い)な3D APIは存在します。コンソールゲーム機の独自グラフィックスAPIなどがその代表格で、コマンドのエンコードやコマンドキューへのキューイングをはじめとした、WebGLなどではドライバ層がやっていたような処理の多くをプログラマが制御することができます。当然、プログラミング難易度は高いですが、その分、注意深く扱えば、非常に高いパフォーマンスを発揮するのです。

 最近ですと、一般のパソコン用にもそうした低レベルAPIが出てきています。Direct3Dのバージョン12、OpenGL Nextと呼ばれていたVulkan、Appleが推進しているMetalなどがそうです。
 現在はGPUの猛烈な進化に比べてCPU性能の向上が頭打ちになってきています。そのような状況の中、従来の3D APIではドローコールを多く発生させるとCPU負荷が急激に高まり、GPUの性能を使い切れないケースが多く見られるようになってきました。VulkanやDirect3D 12などの低レベルAPIは、そうした状況を打開すべく開発され、今後はこうしたAPIが主流を占めていくと思われます。
 (当然、そんなWebGL/OpenGLよりも低レベルなAPIなんてとても使いこなせないよ! という人がほとんどであろうと思われます。今後、これらの低レベルAPIを使っていく人たちは、いわゆるゲームエンジン開発者が主流となり、一般的なプログラマーは、彼らが開発したゲームエンジンを使って3Dグラフィックスを作る時代になっていくのかもしれません。)

 さて、話が大分それましたが、WebGLでも描画タイミングの制御が全くできないわけではありません。非常に大雑把な制御ですが、2つの関数があります。gl.flushgl.finishです。
 gl.flushは、その名が示す通り、「それまでキューに溜まっていた全てのコマンドを実行せよ」という関数になります。つまり、gl.drawArraysやgl.drawElementsなどを実行してもまだ描画が始まっていなかった場合、gl.flushを呼び出すと、すぐにGPUに命令が送り込まれ、実行されます。
 また、gl.finishは、gl.flushと似ているのですが、より徹底的です。「それまでキューに溜まっていた全てのコマンドを実行させ、さらに、その実行が全て完了するまで待機する」という特徴を持っています(具体的に言うと、gl.finish()関数を呼び出した時点で、発行した全ての描画が完了するまで、JavaScriptの制御は一時停止(待機)します)。いわば「同期的な関数」と言えます。

 よほど「描画完了を確実に待って、処理をしなければ結果が誤る可能性がある」というような特殊なケースでもない限り、通常はgl.flushで大丈夫です。下手にgl.finishを乱用すると、CPUやGPUパイプラインが仕事をしない時間が発生してしまい、パフォーマンスが低下してしまいます。
 また、WebGLライブラリやブラウザのWebGL実装などで、swapbuffer系の関数があった場合、それらの関数は内部でgl.flushを呼び出していますので、それらの関数を使っている場合は、あえてその手前でgl.flushを自前で呼び出す必要は特にありません。

 ちなみに、WebGLの元となったOpenGLでは、環境によってはNVIDIAやAMDの拡張命令で、もう少し細かい描画タイミングの制御ができるようになっています。

 あれ、なんか本記事の主題から逸れちゃいましたね^^; まぁ、描画タイミングについての知識も無駄にはならないので。

 (注:gl.finishですが、実を言うと多くのGPUベンダーが「高速化」のために、この関数を「完全に同期的」には実装していません。つまり、gl.finishから制御が戻っても、GPUがまだ若干仕事を残している可能性があるのです。その程度がどれくらいか、はおそらくベンダー依存です。しっかり仕様が決まっているならまだしも、そんなんではとても使えたものではありませんね。まだ遅くてもいいから、完全に同期的に実装してくれた方がスッキリします。完全に同期的なら、

var starttime = getTime();

gl.drawElements(...);

gl.finish();

var endtime = getTime();

console.log('この描画処理にかかった時間は:'+ endtime - starttime);

 というような、計測関係にも使えるのですが、同期性が完全に保証されない(しかもGPU製品間で程度が違う)となると、このような計測方法は使えません。gl.finish、なんとも使い所がよくわからない関数です。もし、「こういうことには使える」という方がいたら是非コメントください^^;)
 

最後に

 だいぶ余談が多く、遠回りしちゃいましたが(っていうか本筋よりも余談の方が分量多いってどうなのよw)いかがでしたか?
 インターリーブ配列は柔軟で、あなたの目的に合わせた頂点属性も含めた、どのような頂点構造にも対応することができます(実際の3Dアプリケーションでは、頂点座標、法線、テクスチャ座標の他にも接線ベクトルなど、他にも必要な頂点属性があることがありますが、そういう場合でもこのやり方は使える、ということですね)。

 インデックスバッファ、gl.TRIANGLE_STRIP、インターリーブ配列、これらの使用はGPUで最適なパフォーマンスを出すための基本中の基本です。パフォーマンスが命のゲーム業界などでは、もしかしたらさらにギチギチなチューニングを行っている可能性がありますが、まぁ一般用途では、この基本原則を守るだけでも十分でしょう。

 こうした原則を知らずに、WebGL/OpenGLを使っている人が思いの外多い気がしていたので、今回この記事を書かせていただきました。

 皆さんも、この基本原則を守って、WebGLのパフォーマンスを引き出しましょう!

 ※もし、これまでの説明で間違いを見つけられた方は、遠慮なくコメントにてご指摘ください。
 
 ※頂点系の設定で関連するトラブルシューティング記事として『WebGLで複数のシェーダー使用時にハマる罠「俺のVBOがアタッチされてないわけがない!」』も公開中です。大抵の人が一度はハマるWebGLトラブルについて書いていますのでこちらも是非。


  1. と書きましたが、実際的なことを書くと、どんな条件下でもGPUのVRAM上にバッファが確保されるとは限りません。gl.bufferData関数の第3引数は、WebGLに対して「どのような用途にそのバッファを使うか」ということを教えることで、WebGLにパフォーマンス最適化のためのヒントを与えるものなのですが、gl.STATIC_DRAW(一旦データを送ったら、それ以降データを変更することがない場合に使う)以外の指定をすると、環境によってはVRAMではなくCPU側のメモリにバッファが作られることもあるようです。ここら辺の挙動はWebGLでは定めておらず、各WebGL環境の実装に任されています。なんですが、まぁ大体の場合は、VBOを使うことで(頂点配列等よりも)GPUにとって高速なアクセスが可能なメモリ領域にバッファが作られます。一般的なパソコンでは、gl.STATIC_DRAWを指定すれば、まず間違いなくGPUのVRAM上にバッファが作られるでしょう。スマホ・タブレットの場合は、そもそもハードウェアとして物理上同一のメモリを、CPU用/GPU用でそれぞれ分け合っているケースが大半です。が、それでもgl.STATIC_DRAWを指定すれば、GPU側の管理メモリ領域の方にバッファが作られることになると思います。 

213
202
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
213
202