床井研究室

OpenGL 3.0 と GLSL 1.3

OpenGL 3.0GLSL 1.3 のドキュメントを軽く眺めていたんですが、ちょっと気が滅入ってきました。最初にこれを読んで私が驚いた The Deprecation Model というのは、固定機能を使うのをやめてプログラマブルシェーダに移行することを推奨するものなんですね。後方互換性は維持されているので「現時点では」まだ焦る必要はないと思うのですが、将来の仕様変更に対する前方互換性を確保しようとすると、glBegin() / glEnd() が使えないばかりか、glVertexPointer() 等を使って頂点配列を指定することすらできません。変換行列の操作や光源の設定、材質の設定なんかも一切できなくなってしまいます。

このため GLSL の側でも、ftransform() が使えないというより、gl_Vertexgl_ModelViewProjectionMatrix など、固定機能に由来する一切の組み込み変数が使えなくなります。つまり、頂点のデータは glVertexAttribPointer() を使ってユーザ定義の attribute(vertex shader では in になってしまうわけですが)変数で与え、変換行列もアプリケーション側で配列に値を設定してからユーザ定義の uniform 変数で与えることになります。そしてこれらを使ってシェーダで座標変換や陰影計算を行わなければなりません。さらに、この描画にはバッファオブジェクトを使うようです。

確かに、これは以前から言われていたことであり、これが新しいグラフィックスハードウェアのアーキテクチャを抽象化した結果なんでしょう。しかし、現実に移行しなければならないとなると、これまで固定機能がやってくれていたことをみんな自分で実装しないといけません。固定機能は結構シェーダプログラミングを助けてくれていたんですね。また、このプログラミングを教える場合にも、とっかかりが難しくなりそうです。将来、状態をオブジェクトにまとめるような書き方になってしまうと、もう昔の OpenGL のプログラムとは似ても似つかないものになっているでしょうね。GLU はどうなるのかな。赤本とかもどうするんでしょう?

Vertex Buffer Object (VBO)

それで、試しにこの制限でプログラムを書いてみようと思いました。まだドライバを OpenGL 3.0 対応にしていないので真似事に過ぎないんですが、それでもこれは実に面倒に思えます。gluLookAt()glFrustum() / gluPerspective() などに相当する関数を書くのは簡単ですし、glRotated() なんかもそれほど手間はかからないでしょう。だから、やり始めてしまえば何とかなると思うんですが、どういう attribute 変数や uniform 変数を準備しておくかとか、全体的な見通しを決めるのには、ちょっと慣れというか試行錯誤が必要になりそうです。そこで、今更ですが、OpenGL 1.5 から標準機能となった Vertex Buffer Object (VBO) を手始めに使ってみたいと思います。

API を準備する

まず、VBO を使うための API を準備します。にも書いていますが、これは GLEW を使うと手軽にできます。しかし、ここでは例によって自分で準備することにします。

#if defined(_WIN32)
#  define _USE_MATH_DEFINES
#  define _CRT_SECURE_NO_WARNINGS
#  include <GL/glut.h>
#  include <GL/glext.h>
PFNGLGENBUFFERSPROC glGenBuffers;
PFNGLISBUFFERPROC glIsBuffer;
PFNGLBINDBUFFERPROC glBindBuffer;
PFNGLBUFFERDATAPROC glBufferData;
PFNGLBUFFERSUBDATAPROC glBufferSubData;
PFNGLMAPBUFFERPROC glMapBuffer;
PFNGLUNMAPBUFFERPROC glUnmapBuffer;
PFNGLDELETEBUFFERSPROC glDeleteBuffers;
#elif defined(__APPLE__) || defined(MACOSX)
#  define GL_SILENCE_DEPRECATION
#  include <GLUT/glut.h>
#else
#  define GL_GLEXT_PROTOTYPES
#  include <GL/glut.h>
#endif

...

void init(void)
{
#if defined(_WIN32)
  glGenBuffers =
    (PFNGLGENBUFFERSPROC)wglGetProcAddress("glGenBuffers");
  glIsBuffer =
    (PFNGLISBUFFERPROC)wglGetProcAddress("glIsBuffer");
  glBindBuffer =
    (PFNGLBINDBUFFERPROC)wglGetProcAddress("glBindBuffer");
  glBufferData =
    (PFNGLBUFFERDATAPROC)wglGetProcAddress("glBufferData");
  glBufferSubData =
    (PFNGLBUFFERSUBDATAPROC)wglGetProcAddress("glBufferSubData");
  glMapBuffer =
    (PFNGLMAPBUFFERPROC)wglGetProcAddress("glMapBuffer");
  glUnmapBuffer =
    (PFNGLUNMAPBUFFERPROC)wglGetProcAddress("glUnmapBuffer");
  glDeleteBuffers =
    (PFNGLDELETEBUFFERSPROC)wglGetProcAddress("glDeleteBuffers");
#endif

  ...
}

ちなみに Linux では、nVIDIA のプロプライエタリドライバを使えば libGL にこれらのエントリポイントが入っているんですが、AMD (ATI) のドライバだと用意してくれないみたいですね。この場合は glXGetProcAddress() を使って、Windows と同じように API のエントリポイントを取り出す必要があります。

頂点配列

前回に書いた頂点配列による描画手順は、まとめると次のようになります。

/* 頂点データ */
static GLfloat vert[][3] = {
  ...
};

/* 法線データ */
static GLfloat norm[][3] = {
  ...
};

/* テクスチャ座標 */
static GLfloat texc[][2] = {
  ...
};

/* 頂点のインデックス */
static GLuint face[][3] = {
  ...
};

...

/* 三角形の数 */
static int nf = sizeof face / sizeof face[0];

...

/*
** 図形の表示
*/
void display(void)
{
  ...

  /* 頂点データ、法線データ、テクスチャ座標の配列を有効にする */
  glEnableClientState(GL_VERTEX_ARRAY);
  glEnableClientState(GL_NORMAL_ARRAY);
  glEnableClientState(GL_TEXTURE_COORD_ARRAY);

  /* 頂点データ、法線データ、テクスチャ座標の場所を指定する */
  glVertexPointer(3, GL_FLOAT, 0, vert);
  glNormalPointer(GL_FLOAT, 0, norm);
  glTexCoordPointer(2, GL_FLOAT, 0, texc);

  /* 頂点のインデックスの場所を指定して図形を描画する */
  glEnable(GL_TEXTURE_2D);
  glDrawElements(GL_TRIANGLES, nf * 3, GL_UNSIGNED_INT, face);
  glDisable(GL_TEXTURE_2D);

  /* 頂点データ、法線データ、テクスチャ座標の配列を無効にする */
  glDisableClientState(GL_VERTEX_ARRAY);
  glDisableClientState(GL_NORMAL_ARRAY);
  glDisableClientState(GL_TEXTURE_COORD_ARRAY);

  ...
}

バッファオブジェクトの作成

VBO の場合は、まずグラフィックスサブシステム側にメモリを確保(バッファオブジェクトを作成)し、あらかじめそこにデータを転送しておきます。

...

/* バッファオブジェクトの名前を4つ用意する */
static GLuint buffers[4];

...

/*
** 初期化
*/
void init(void)
{
  ...

  /* バッファオブジェクトの名前を4つ作る */
  glGenBuffers(4, buffers);

  /* 1つ目のバッファオブジェクトに頂点データ配列を転送する */
  glBindBuffer(GL_ARRAY_BUFFER, buffers[0]);
  glBufferData(GL_ARRAY_BUFFER, sizeof vert, vert, GL_STATIC_DRAW);

  /* 2つ目のバッファオブジェクトに法線データ配列を転送する */
  glBindBuffer(GL_ARRAY_BUFFER, buffers[1]);
  glBufferData(GL_ARRAY_BUFFER, sizeof norm, norm, GL_STATIC_DRAW);

  /* 3つ目のバッファオブジェクトにテクスチャ座標配列を転送する */
  glBindBuffer(GL_ARRAY_BUFFER, buffers[2]);
  glBufferData(GL_ARRAY_BUFFER, sizeof texc, texc, GL_STATIC_DRAW);

  /* 4つ目のバッファオブジェクトに頂点のインデックスを転送する */
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buffers[3]);
  glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof face, face, GL_STATIC_DRAW);

  ...
}

VBO による描画

そして、次の手順で描画を行います。これは頂点配列の描画手順に似ています。

...

#define BUFFER_OFFSET(bytes) ((GLubyte *)NULL + (bytes))

...

/*
** 図形の表示
*/
void display(void)
{
  ...

  /* 頂点データ、法線データ、テクスチャ座標の配列を有効にする */
  glEnableClientState(GL_VERTEX_ARRAY);
  glEnableClientState(GL_NORMAL_ARRAY);
  glEnableClientState(GL_TEXTURE_COORD_ARRAY);

  /* 頂点データの場所を指定する */
  glBindBuffer(GL_ARRAY_BUFFER, buffers[0]);
  glVertexPointer(3, GL_FLOAT, 0, BUFFER_OFFSET(0));

  /* 法線データの場所を指定する */
  glBindBuffer(GL_ARRAY_BUFFER, buffers[1]);
  glNormalPointer(GL_FLOAT, 0, BUFFER_OFFSET(0));

  /* テクスチャ座標の場所を指定する */
  glBindBuffer(GL_ARRAY_BUFFER, buffers[2]);
  glTexCoordPointer(2, GL_FLOAT, 0, BUFFER_OFFSET(0));

  /* 頂点のインデックスの場所を指定して図形を描く */
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buffers[3]);
  glEnable(GL_TEXTURE_2D);
  glDrawElements(GL_TRIANGLES, nf * 3, GL_UNSIGNED_INT, BUFFER_OFFSET(0));
  glDisable(GL_TEXTURE_2D);

  /* 頂点データ、法線データ、テクスチャ座標の配列を無効にする */
  glDisableClientState(GL_VERTEX_ARRAY);
  glDisableClientState(GL_NORMAL_ARRAY);
  glDisableClientState(GL_TEXTURE_COORD_ARRAY);

  ...
}

頂点配列の場合と異なるのは、データの場所を指定する際に glBindBuffer() を使ってバッファオブジェクトをバインドしておき、glVertexPointer() 等で指定するデータの場所を、バッファオブジェクトの先頭からのオフセットで指定するという点です。

BUFFER_OFFSET(bytes) というマクロは赤本から借りました。これは bytesunsigned char にキャストするのとあんまり変わりませんが、unsigned char が1バイトでない(unsigned char のポインタに1を足したときにアドレスが1より大きく増える)システム(あるのか?)でもオフセットを正しく計算できます。

バッファオブジェクトの削除

使わなくなったバッファオブジェクトは、glDeleteBuffers() を使って削除します。

glDeleteBuffers(4, buffers);

バッファオブジェクトをまとめる

先の例ではバッファオブジェクトを4つ作っていましたが、GL_ARRAY_BUFFER にバインドするバッファオブジェクトは、ひとつにまとめることもできます。まず、vertnorm、および texc を合計したサイズのバッファオブジェクトを確保します。

...

#define BUFFER_OFFSET(bytes) ((GLubyte *)NULL + (bytes))

...

/* バッファオブジェクトの名前を2つ用意する */
static GLuint buffers[2];

...

/*
** 初期化
*/
void init(void)
{
  ...

  /* バッファオブジェクトの名前を2つ作る */
  glGenBuffers(2, buffers);

  /* 1つ目のバッファオブジェクトに頂点、法線、テクスチャ座標を合わせた領域を確保する */
  glBindBuffer(GL_ARRAY_BUFFER, buffers[0]);
  glBufferData(GL_ARRAY_BUFFER, sizeof vert + sizeof norm + sizeof texc, NULL, GL_STATIC_DRAW);

glBufferData() の第3引数に NULL を指定しているので、ここではバッファオブジェクトの確保のみが行われ、バッファオブジェクトの初期化(データの転送)は行われません。データの転送は、この後 glBufferSubData() を使って行います。

  /* バッファオブジェクトの先頭に頂点データを転送する */
  glBufferSubData(GL_ARRAY_BUFFER, BUFFER_OFFSET(0), sizeof vert, vert);
  /* バッファオブジェクトの頂点データの次に法線データを転送する */
  glBufferSubData(GL_ARRAY_BUFFER, BUFFER_OFFSET(sizeof vert), sizeof norm, norm);
  /* バッファオブジェクトの法線データの次にテクスチャ座標を転送する */
  glBufferSubData(GL_ARRAY_BUFFER, BUFFER_OFFSET(sizeof vert + sizeof norm), sizeof texc, texc);

頂点のインデックスの転送は、以前と同じです。

  /* 2つ目のバッファオブジェクトに頂点のインデックスを転送する */
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buffers[1]);
  glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof face, face, GL_STATIC_DRAW);

  ...
}

描画のときは、glVertexPointer() 等で、データの場所としてデータを転送したバッファオブジェクトのオフセットを指定します。

/*
** 図形の表示
*/
void display(void)
{
  ...

  /* 頂点データ、法線データ、テクスチャ座標の配列を有効にする */
  glEnableClientState(GL_VERTEX_ARRAY);
  glEnableClientState(GL_NORMAL_ARRAY);
  glEnableClientState(GL_TEXTURE_COORD_ARRAY);

  /* 頂点データ、法線データ、テクスチャ座標の場所を指定する */
  glBindBuffer(GL_ARRAY_BUFFER, buffers[0]);
  glVertexPointer(3, GL_FLOAT, 0, BUFFER_OFFSET(0));
  glNormalPointer(GL_FLOAT, 0, BUFFER_OFFSET(sizeof vert));
  glTexCoordPointer(2, GL_FLOAT, 0, BUFFER_OFFSET(sizeof vert + sizeof norm));

  /* 頂点のインデックスの場所を指定して図形を描く */
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buffers[1]);
  glEnable(GL_TEXTURE_2D);
  glDrawElements(GL_TRIANGLES, nf * 3, GL_UNSIGNED_INT, BUFFER_OFFSET(0));
  glDisable(GL_TEXTURE_2D);

  /* 頂点データ、法線データ、テクスチャ座標の配列を無効にする */
  glDisableClientState(GL_VERTEX_ARRAY);
  glDisableClientState(GL_NORMAL_ARRAY);
  glDisableClientState(GL_TEXTURE_COORD_ARRAY);

  ...
}

複数のデータを一つのバッファオブジェクトにまとめるには、他に glInterleavedArrays() を使う方法があるのですが、サンプルプログラムのデータ構造を変えないと使えないので、今回は割愛します。頂点の位置や法線ベクトル、テクスチャ座標などを構造体を使って一つの頂点ごとにまとめているような場合は、glInterleavedArrays() を使うことになると思います。

補足

コメントで頂きましたように、一つの頂点の頂点属性をこの例のように離れたところに配置するより、頂点ごとにまとめた方がキャッシュのヒット率やデータの転送効率などの点で有利だと思われます(インターリーブの配置)。これに関して、エマ・デュランダルさまより詳しい解説を頂きました。ありがとうございます!

サンプルプログラム

今回は説明のプログラムと作ったサンプルプログラムが一致していないので、説明のプログラムがちゃんと動くかどうかわかりません。