頂点配列とVBO

連載一覧 "re:ゼロから始めるOpenGL 2.1"

ついに!ついにVBOの話です。

前回まで視点などの描画設定を行ってきました。

これでかなり自分の好きなように動かせるようになってきたと思います。

今回はより効率の良い描画を行う方法を学ぶことになります。

頂点配列

今まではglBegin()、glVertex3f()関数などでGLSL側にデータを送ってきました。

前にも言いましたがglBegin()などはすでに廃止や廃止予定となっています。

これらの関数で描画すると非常に遅いです。

C++側からGLSL側に頂点1つ1つのデータを送って、そのたびに「こういう処理をしてほしい」って指示も送るもんだから遅くて当然です。

イメージで説明すると業務を振り分けるのが得意な管理職C++はデータの処理が得意な部下GLSLに仕事を与えるわけです。

この時、小さな仕事や書類を数十秒ごとに指示していると、部下は聞きに行く時間が無駄になるわけですし、上司のほうも指示に気を取られて他の仕事ができません。

逆に上司C++が1度に書類を渡して、仕事を説明しておけばGLSLは自分の机で黙々と作業を進められますし、上司も違う仕事に手を付けられます。

そんなわけでデータは頂点配列としてGLSLに渡して、渡した頂点に対して1度で描画処理を行うわけです。

それにはglVertexPointer()などの関数を使うのですが、現在ではVBOという仕組みを使うのでこの関数は使われてないです。

VBO(Vertex Buffer Object)

アニメーションというのはパラパラ漫画です。

多くのディスプレイでは最大60fpsとなっています。
これは1秒間に60回画面を更新できるということです。

つまり、この最大回数で描画を行うということは、1秒間に60回もglVertexPointer()などでデータをGPU(GLSL)に転送し、GLSLで処理をして、glfwSwapBuffers()で画面を更新しているということです。

これでは送る頂点データの量が増えてしまうと致命的に遅くなってしまいます。
なぜなら、GPUというパーツは当然ケーブルでPCに接続されていて、その転送速度には上限があるからです。

今ではその転送速度はかなり速くなっていますが、さすがに1秒間に何十回も大量の頂点データを送れるわけではありません。

そこでVBOという仕組みでは、先に頂点データをGPU側に送っておいて、そのデータを描画のたびに使うというものです。

すると頂点データの伝送は初回だけでいいわけです。

初期化時に以下のような感じで準備していきます。
std::vectorはC++の標準機能で変数名の後ろに.size()とつけるだけで配列数を測れたり、動的にサイズ変更できたり非常に便利なので使わないと損です。

// 2枚の三角ポリゴン
std::vector<vec3> positionList = {
vec3(0, 0, 1),vec3( 1,0, 0),vec3( 0, 0, 0),
vec3(0, 0, 1),vec3( 0, 0, 0),vec3(0, 1, 0),
};
// attribute を指定する
GLint positionLocation = glGetAttribLocation(shader, "position");
// 頂点バッファオブジェクトを作成
GLuint positionBuffer;
glGenBuffers(1, &positionBuffer);
// GPU側に頂点バッファオブジェクトにメモリ領域を確保する
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(vec3) * positionList.size(), &positionList[0], GL_STATIC_DRAW);

次にループ内のglBegin()のあたりを変更していきます。

// positionLocationで指定されたattributeを有効化
glEnableVertexAttribArray(positionLocation);
// positionBufferにバインド
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
// attribute変数positionに割り当てる
// GPU内メモリに送っておいたデータをバーテックスシェーダーで使う指定です
//第3引数は頂点がvec3なので3
glVertexAttribPointer(positionLocation, 3, GL_FLOAT, GL_FALSE, 0, (void*)0);
// GLSL実行 第2,第3引数は頂点配列のどこからどこまでを実行するか
glDrawArrays(GL_TRIANGLES, 0, 6);

GLSLのバーテックスシェーダーでattribute変数を受け取らなければなりません。

#version 120
//
// shader.vert
//
uniform mat4 MVP;
attribute vec3 position;
void main(void)
{
gl_Position = MVP * vec4(position, 1.0);
gl_FrontColor = vec4(1.0, 0.0, 0.0, 1.0);
}

簡略化のため、色はこっちで指定しています。

gl_Vertexの代わりに作成したpositionを使っています。

gl_Positionはvec4なのでpositionもvec4に変換してやらないといけません。

main関数はこんな感じです。

int main()
{
GLint width = 640, height = 480;
GLFWwindow* window = initGLFW(width, height);
GLint shader = makeShader("shader.vert", "shader.frag");
// 2枚の三角ポリゴン
std::vector<vec3> positions = {
vec3(0, 0, 1),vec3( 1,0, 0),vec3( 0, 0, 0),
vec3(0, 0, 1),vec3( 0, 0, 0),vec3(0, 1, 0),
};
// attribute を指定する
GLint positionLocation = glGetAttribLocation(shader, "position");
// 頂点バッファオブジェクトを作成
GLuint positionBuffer;
glGenBuffers(1, &positionBuffer);
// GPU側に頂点バッファオブジェクトにメモリ領域を確保する
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(vec3) * positions.size(), &positions[0], GL_STATIC_DRAW);
GLuint matrixID = glGetUniformLocation(shader, "MVP");
// フレームループ
while (glfwWindowShouldClose(window) == GL_FALSE)
{
glUseProgram(shader);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
glClearColor(0.2f, 0.2f, 0.2f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 宣言時には単位行列が入っている
mat4 modelMat, viewMat, projectionMat;
// View行列を計算
viewMat = glm::lookAt(
vec3(2.0, 2.0, 2.0), // ワールド空間でのカメラの座標
vec3(0.0, 0.0, 0.0), // 見ている位置の座標
vec3(0.0, 0.0, 1.0)  // 上方向を示す。(0,1.0,0)に設定するとy軸が上になります
);
// Projection行列を計算
projectionMat = glm::perspective(
glm::radians(45.0f), // ズームの度合い(通常90~30)
(GLfloat)width / (GLfloat)height,		// アスペクト比
0.1f,		// 近くのクリッピング平面
100.0f		// 遠くのクリッピング平面
);
// ModelViewProjection行列を計算
mat4 mvpMat = projectionMat * viewMat* modelMat;
// 現在バインドしているシェーダのuniform変数"MVP"に変換行列を送る
// 4つ目の引数は行列の最初のアドレスを渡しています。
glUniformMatrix4fv(matrixID, 1, GL_FALSE, &mvpMat[0][0]);
// positionLocationで指定されたattributeを有効化
glEnableVertexAttribArray(positionLocation);
// positionBufferにバインド
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
// attribute変数positionに割り当てる
// GPU内メモリに送っておいたデータをバーテックスシェーダーで使う指定です
glVertexAttribPointer(positionLocation, 3, GL_FLOAT, GL_FALSE, 0, (void*)0);
glDrawArrays(GL_TRIANGLES, 0, 6);
// ダブルバッファのスワップ
glfwSwapBuffers(window);
glfwPollEvents();
}
// GLFWの終了処理
glfwTerminate();
return 0;
}

これで表示できますが、まだ効率が悪いことがあります。

下の図が今回の頂点座標のイメージなのですが、頂点は4つしかありません。

しかし、プログラム中の頂点配列は6つあります。

バーテックスシェーダーは頂点配列の数と同じ回数実行されますから、同じ頂点に同じ処理をするのは非効率です。

これを解決する仕組みが頂点インデックスです。
これは上の図の様にインデックスによってどの頂点でポリゴンを作成するのか指定してあげることで同じ座標の頂点を複数保持しなくていいようにします。

実際に書いてみましょう。
まずは、頂点配列とインデックス配列を作成します。

先ほどと同じポリゴンを作る配列です。
indicesで頂点配列の何番目を使うか指定してます。

// 2枚の三角ポリゴン
std::vector<vec3> positions = {
vec3( 0, 0, 0),vec3(1, 0, 0),vec3( 0, 1, 0),vec3(0, 0, 1),
};
std::vector<GLuint> indices = {3, 1, 0, 3, 0, 2};

余談ですが、indexの複数形はindicesになります。
プログラマーの好みによってはindexesと書く人もいます。

細かいことを言えばindexesは索引の意味、indicesは配列などの添え字で使うのが正しい使い方のようです。

main関数

int main()
{
GLint width = 640, height = 480;
GLFWwindow* window = initGLFW(width, height);
GLint shader = makeShader("shader.vert", "shader.frag");
// 2枚の三角ポリゴン
std::vector<vec3> positions = {
vec3( 0, 0, 0),vec3(1, 0, 0),vec3( 0, 1, 0),vec3(0, 0, 1),
};
std::vector<GLuint> indices = {3, 1, 0, 3, 0, 2};
// attribute を指定する
GLint positionLocation = glGetAttribLocation(shader, "position");
// 頂点バッファオブジェクトを作成
GLuint buffers[2];
glGenBuffers(2, &buffers[0]);
// GPU側にindices分の頂点バッファオブジェクトにメモリ領域を確保する
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buffers[0]);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(GLuint) * indices.size(), &indices[0], GL_STATIC_DRAW);
// GPU側にpositions分の頂点バッファオブジェクトにメモリ領域を確保する
glBindBuffer(GL_ARRAY_BUFFER, buffers[1]);
glBufferData(GL_ARRAY_BUFFER, sizeof(vec3) * positions.size(), &positions[0], GL_STATIC_DRAW);
// 頂点バッファオブジェクトを解放する
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
GLuint matrixID = glGetUniformLocation(shader, "MVP");
// フレームループ
while (glfwWindowShouldClose(window) == GL_FALSE)
{
glUseProgram(shader);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
glClearColor(0.2f, 0.2f, 0.2f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 宣言時には単位行列が入っている
mat4 modelMat, viewMat, projectionMat;
// View行列を計算
viewMat = glm::lookAt(
vec3(2.0, 2.0, 2.0), // ワールド空間でのカメラの座標
vec3(0.0, 0.0, 0.0), // 見ている位置の座標
vec3(0.0, 0.0, 1.0)  // 上方向を示す。(0,1.0,0)に設定するとy軸が上になります
);
// Projection行列を計算
projectionMat = glm::perspective(
glm::radians(45.0f), // ズームの度合い(通常90~30)
(GLfloat)width / (GLfloat)height,		// アスペクト比
0.1f,		// 近くのクリッピング平面
100.0f		// 遠くのクリッピング平面
);
// ModelViewProjection行列を計算
mat4 mvpMat = projectionMat * viewMat* modelMat;
// 現在バインドしているシェーダのuniform変数"MVP"に変換行列を送る
// 4つ目の引数は行列の最初のアドレスを渡しています。
glUniformMatrix4fv(matrixID, 1, GL_FALSE, &mvpMat[0][0]);
// positionLocationで指定されたattributeを有効化
glEnableVertexAttribArray(positionLocation);
// positionBufferにバインド
glBindBuffer(GL_ARRAY_BUFFER, buffers[1]);
// attribute変数positionに割り当てる
// GPU内メモリに送っておいたデータをバーテックスシェーダーで使う指定です
glVertexAttribPointer(positionLocation, 3, GL_FLOAT, GL_FALSE, 0, (void*)0);
// インデックスを指定するときはglDrawArraysでなくglDrawElements
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buffers[0]);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, (void*)0);
// ダブルバッファのスワップ
glfwSwapBuffers(window);
glfwPollEvents();
}
// GLFWの終了処理
glfwTerminate();
return 0;
}

同様にバッファを作って、有効化してバインドしています。

glDrawElements()関数でインデックスを指定して実行しています。

実行結果。

今回は頂点のみをattribute変数としてGLSLに送りましたが、法線や色、uvなどの情報も頂点と同様の方法で送ることができます。

今回の内容まででかなり効率的なプログラムを作れるようになったと思います。

ちなみにvao(Vertex Array Object)という言葉があって非常に紛らわしいのですが、これはvboの仲間や別機能ではなく、vboをカプセル化するためのものです。
(確かOpenGL3.xからの標準機能です)

vboの機能を使ってみると関連する記述が多かったと思います。
するとコードは読みにくくなりますし、見栄えも悪いです。

vboの機能を簡潔に利用するための仕組みです。

連載一覧 "re:ゼロから始めるOpenGL 2.1"

あわせて読みたい