GLSLシェーダーを触ってみよう

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

今回やっと実用的な内容に入っていきます。

こうやって解説文を書いていて思ったのですが、読んでくださっている読者の前提知識をどこに合わせたらいいのかわからず結構迷走しているような気がします。

わからないことが出てきたらその都度調べてください。
これが1番です。

僕もこの解説を書きながら頻繁に調べ物をしています。
人に教えるって難しい。

ビューイングパイプライン

OpenGLが世界を描画するにあたって、描画までの一通りの流れをパイプラインといいます。

下の画像にはその流れをざっくりと示しています。

その中でも大切なのがバーテックスシェーダとフラグメントシェーダです。

これらは私たちプログラマーがGLSLという言語を使って記述していきます。

バーテックスシェーダは頂点ごとに実行されます。
つまり、3つの頂点があれば3回実行されることになります。

フラグメントシェーダはピクセルごとに実行されます。
ピクセルはパソコンの画面上にあるドット1つ1つの単位です。

下の図は表示されたウィンドウのイメージです。
実際にこんなに1つのピクセルが大きいわけがないのですが、書くのがめんどくさかったわかりやすくするために大きくしています。

このピクセル1つ1つの色を計算していくことによって、最終的に出力する画像を出力します。

ポリゴンを表示する

ローカル座標系の範囲は(-1,-1)から(1,1)までとなっています。

画面に出力されるときには指定した比率に引き延ばされます。
これを次のプログラムで見ていきます。

#include "stdafx.h"
#include <iostream>
#include <gl/glew.h>
#include <GLFW/glfw3.h>
int main()
{
// GLFW初期化
if (glfwInit() == GL_FALSE) 
{
return -1;
}
// ウィンドウ生成
GLFWwindow* window = glfwCreateWindow(640, 480, "OpenGL Simple", NULL, NULL);
if (!window)
{
glfwTerminate();
return -1;
}
// バージョン2.1指定
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 2);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 1);
glfwMakeContextCurrent(window);
glfwSwapInterval(0);
// GLEW初期化
if (glewInit() != GLEW_OK) 
{
return -1;
}
// フレームループ
while (glfwWindowShouldClose(window) == GL_FALSE) 
{
// バッファのクリア
glClearColor(0.2f, 0.2f, 0.2f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 色指定
glColor4f(1.0, 0.0, 0.0, 1.0);
// 3つの頂点座標をGPUに転送
glBegin(GL_TRIANGLES);
glVertex2f(   0,  0.5);
glVertex2f(-0.5, -0.5);
glVertex2f( 0.5, -0.5);
glEnd();
// ダブルバッファのスワップ
glfwSwapBuffers(window);
glfwPollEvents();
}
// GLFWの終了処理
glfwTerminate();
return 0;
}

実行結果はこのようになります。
よく見たらOpenGL Simpleになってる…(^_^;)

前回のプログラムから変わっているのは以下の部分です。

		// 色指定
glColor4f(1.0, 0.0, 0.0, 1.0);
// 3つの頂点座標をGPUに転送
glBegin(GL_TRIANGLES);
glVertex2f(   0,  0.5);
glVertex2f(-0.5, -0.5);
glVertex2f( 0.5, -0.5);
glEnd();

色を指定したのち、glBeginからglEndまでの間は3つの頂点による三角形ポリゴンを描画しています。

ただ実はglBeginやglEndはOpenGL 3.0で廃止予定、OpenGL 3.1で廃止されています。
じゃあなんで使ったのかというと、説明するのに便利だったからです。

OpenGLは現在VBOという仕組みを用いて描画を行うのが主流なのですが、その仕組みが結構複雑なので今回のは古いコードを用いています。
VBOの解説をしたらもう出てきません。

GLSLシェーダーファイルの読み込み

まずはGLSLシェーダープログラムを記述するファイルを作成します。

GLSLはC言語を拡張した言語でGPU内での処理を記述していきます。

拡張子についてはシェーダーだと分かれば基本何でもいいです。
「.vert/.frag」としている人が多いですが、「.vertexshader/.fragmentshader」としている人もいたりします。

ここでは「.vert/.frag」として、ソースコードと同じディレクトリにファイルを作成しています。

作り方ですが、VisualStudioの新規作成からファイル→テキストファイルと進み、「.txt」ファイルを作成した後に拡張子を含めたファイル名を変更するといいです。

非常に面白いことにGLSLはプログラムの実行時にシェーダーソースコードをコンパイルします。
これは各自のGPUに最適化するためです。

先ほど作ったシェーダーファイルを読み込むプログラムを作っていきましょう。

ここで衝撃の事実ですが、OpenGLにはこれらのファイルを読み込んでくれる関数は存在しません。
つまり自分で作らないといけないのです(笑)

C++で作ったのでC言語で書くよりもよっぽどシンプルだと思います。
というわけで作った関数が以下のものになります。

// 省略
#include <iostream>
#include <string>
#include <fstream>
#include <sstream>
// 省略
int readShaderSource(GLuint shaderObj, std::string fileName)
{
//ファイルの読み込み
std::ifstream ifs(fileName);
if (!ifs)
{
std::cout << "error" << std::endl;
return -1;
}
std::string source;
std::string line;
while (getline(ifs, line))
{
source += line + "\n";
}
// シェーダのソースプログラムをシェーダオブジェクトへ読み込む
const GLchar *sourcePtr = (const GLchar *)source.c_str();
GLint length = source.length();
glShaderSource(shaderObj, 1, &sourcePtr, &length);
return 0;
}

今更ですが、プログラム中に「std::〇〇」というものがありますが、これはstdという名前のネームスペースにある機能なんだと思っていてください。
今回の趣旨とは関係ないのでここでは説明しません。

インクルードしているものでstringは文字列を扱うもので、fstreamとsstreamはファイル読み込み関連です。

readShaderSourceの仮引数shaderObgはシェーダーオブジェクトでこれについては後で出てきます。
同様に仮引数fileNameはシェーダーファイル名、今回の例でいえば”shader.vert”や”shader.frag”を受け取ります。

	std::ifstream ifs(fileName);

でファイルを読み込む準備をし、

	std::string source;
std::string line;
while (getline(ifs, line))
{
source += line + "\n";
}

このwhileループによって、ファイル1行ごとに読み込んでいき、その内容をlineに収めています。

それを改行文字と合わせてsourceの末尾に加えています。
「source += line + “\n”;」は「source = source + line + “\n”;」と同じ意味です。
std::stringは「+」で文字列の結合ができます。

これでシェーダーファイルの中身をすべて文字列sourceに収めることができました。

	// シェーダのソースプログラムをシェーダオブジェクトへ読み込む
const GLchar *sourcePtr = (const GLchar *)source.c_str();
GLint length = source.length();
glShaderSource(shaderObj, 1, &sourcePtr, &length);

このc_str()というのはstd::stringからcharのポインタを取り出してくれます。
なにこれ超便利です。

source.length()によってsource文字列の長さを取得できます。

これらをglShaderSource()の引数に与えてやればシェーダーファイルの読み込みは完了です。

シェーダー実行のためプログラム

シェーダープログラムを有効化する一連の流れを示します。

  1. glCreateShader()でシェーダーオブジェクトを作成
  2. 作成したシェーダーオブジェクトにglShaderSource()でソースコードを読み込む
  3. glCompileShaderでコンパイルします。
  4. glCreateProgram()でプログラムオブジェクトを作成
  5. glAttachShader()でシェーダーオブジェクトをシェーダープログラムへ登録
  6. glDeleteShader()でシェーダーオブジェクトの削除
  7. glLinkProgram()シェーダープログラムをリンク
  8. glUseProgram()でシェーダーを有効化します

glUseProgram()はwhileの前に実行してしまってもいいのですが、物体によってシェーダーを変更したい場合などにはwhileループ内に記述することで実現できます。

なのでこれを簡単に使えるようにmakeShader()関数を作成します。

これはプログラムオブジェクトを返り値にし、そのプログラムオブジェクトをmain関数でglUseProgram()で有効化しています。

#include "stdafx.h"
#include <iostream>
#include <string>
#include <fstream>
#include <sstream>
#include <gl/glew.h>
#include <GLFW/glfw3.h>
int readShaderSource(GLuint shaderObj, std::string fileName)
{
//ファイルの読み込み
std::ifstream ifs(fileName);
if (!ifs)
{
std::cout << "error" << std::endl;
return -1;
}
std::string source;
std::string line;
while (getline(ifs, line))
{
source += line + "\n";
}
// シェーダのソースプログラムをシェーダオブジェクトへ読み込む
const GLchar *sourcePtr = (const GLchar *)source.c_str();
GLint length = source.length();
glShaderSource(shaderObj, 1, &sourcePtr, &length);
return 0;
}
GLint makeShader(std::string vertexFileName, std::string fragmentFileName)
{
// シェーダーオブジェクト作成
GLuint vertShaderObj = glCreateShader(GL_VERTEX_SHADER);
GLuint fragShaderObj = glCreateShader(GL_FRAGMENT_SHADER);
GLuint shader;
// シェーダーコンパイルとリンクの結果用変数
GLint compiled, linked;
/* シェーダーのソースプログラムの読み込み */
if (readShaderSource(vertShaderObj, vertexFileName)) return -1;
if (readShaderSource(fragShaderObj, fragmentFileName)) return -1;
/* バーテックスシェーダーのソースプログラムのコンパイル */
glCompileShader(vertShaderObj);
glGetShaderiv(vertShaderObj, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE)
{
fprintf(stderr, "Compile error in vertex shader.\n");
return -1;
}
/* フラグメントシェーダーのソースプログラムのコンパイル */
glCompileShader(fragShaderObj);
glGetShaderiv(fragShaderObj, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE)
{
fprintf(stderr, "Compile error in fragment shader.\n");
return -1;
}
/* プログラムオブジェクトの作成 */
shader = glCreateProgram();
/* シェーダーオブジェクトのシェーダープログラムへの登録 */
glAttachShader(shader, vertShaderObj);
glAttachShader(shader, fragShaderObj);
/* シェーダーオブジェクトの削除 */
glDeleteShader(vertShaderObj);
glDeleteShader(fragShaderObj);
/* シェーダープログラムのリンク */
glLinkProgram(shader);
glGetProgramiv(shader, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE)
{
fprintf(stderr, "Link error.\n");
return -1;
}
return shader;
}
int main()
{
// GLFW初期化
if (glfwInit() == GL_FALSE) 
{
return -1;
}
// ウィンドウ生成
GLFWwindow* window = glfwCreateWindow(640, 480, "OpenGL Sample", NULL, NULL);
if (!window)
{
glfwTerminate();
return -1;
}
// バージョン2.1指定
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 2);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 1);
glfwMakeContextCurrent(window);
glfwSwapInterval(0);
// GLEW初期化
if (glewInit() != GLEW_OK) 
{
return -1;
}
GLint shader = makeShader("shader.vert", "shader.frag");
// フレームループ
while (glfwWindowShouldClose(window) == GL_FALSE) 
{
// バッファのクリア
glClearColor(0.2f, 0.2f, 0.2f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(shader);
// 色指定
glColor4f(1.0, 0.0, 0.0, 1.0);
// 3つの頂点座標をGPUに転送
glBegin(GL_TRIANGLES);
glVertex2f(   0,  0.5);
glVertex2f(-0.5, -0.5);
glVertex2f( 0.5, -0.5);
glEnd();
// ダブルバッファのスワップ
glfwSwapBuffers(window);
glfwPollEvents();
}
// GLFWの終了処理
glfwTerminate();
return 0;
}

特に説明するところはありませんね。

このプログラムによって実行するシェーダープログラムを書いていきます。

shader.vertとshader.fragの内容はとりあえず以下の様にしておいてください。

#version 120
//
// shader.vert
//
void main(void)
{
gl_Position = gl_Vertex;
gl_FrontColor = gl_Color;
}
#version 120
//
// shader.frag
//
void main(void)
{
gl_FragColor = gl_Color;
}

1行目の#versionはGLSLのバージョンを指定しています。
これは使用するOpenGLのバージョンによって決まっています。

OpenGLバージョン GLSLバージョン #version
1.5 1.0  
2.0 1.1 #version 110
2.1 1.2 #version 120
3.0 1.3 #version 130
3.1 1.4 #version 140
3.2 1.5 #version 150
3.3 3.3 #version 330
4.0 4.0 #version 400
4.1 4.1 #version 410
4.2 4.2 #version 420
4.3 4.3 #version 430
4.4 4.4 #version 440
4.5 4.5 #version 450

今回のプログラムでは頂点が3つあるのでshader.vertが3回実行されます。

そのたびにglVertex2f()によって組み込み変数であるgl_Vertexに頂点座標が転送されます。

gl_Positionは最初のほうで説明したローカル座標系における頂点情報を代入します。
今回はそのままgl_Vertexが受け取った値を代入してます。

shader.vertの方のgl_ColorはglColor4f()にから値を受け取っています。
gl_FrontColorに代入された値はshader.fragのgl_Colorが受け取ります。

gl_FragColorは最終的に出力される色情報です。

今回紹介した組み込み変数gl_〇〇はvec4のベクトルです。
頂点なら(x, y, z, w)、色なら(r,g,b,a)を表しています。

さて、これを実行すると、

同じ結果でこれではきちんとプログラマシェーダーが使われているかわからないので下の様に頂点を動かして色を変えてみます。

#version 120
//
// shader.vert
//
void main(void)
{
gl_Position = gl_Vertex + vec4(0.0, 0.4 ,0.0, 0.0);
gl_FrontColor = gl_Color;
}
#version 120
//
// shader.frag
//
void main(void)
{
gl_FragColor = gl_Color + vec4(0.0, 0.0, 0.6, 0.0);
}

すべての頂点をy軸方向に0.4動かして、青色を加算します。

きちんとシェーダーが反映されていますね(^^)

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

あわせて読みたい