はじめに
前回までの記事で、OpenGLの基本のセットアップとウィンドウ周りの処理、C++でのコード記述、キーボードとマウスの入力処理ができるようになりました。
今回からはシェーダのプログラムを書いて、グラフィック描画の処理に入っていきたいと思います。
1. シェーダを扱うクラスを作成する
シェーダを扱うには、シェーダのソースコードを読み込んでコンパイルし、さらに複数のシェーダを組み合わせるためにリンクするという操作が必要になります。それらの操作をサポートするクラスを作るために、シェーダを扱うクラスを書くためのC++ファイルを作りましょう。
Xcodeのプロジェクト・ナビゲータから StringSupport.mm を右クリックして、「New File…」を実行します。
次に表示されるテンプレートの選択画面では、「macOS」の「C++ File」を選択して、「Next」ボタンを押します。
ファイル名に「Shader」と入力し、「Next」ボタンを押します。次の画面で何も変更せずに「Create」ボタンを押すと、Shader.hppとShader.cppの2つのファイルがプロジェクトに追加されます。
今回はObjective-C++を利用しなければいけない箇所はありませんので、拡張子は「.cpp」のままにしておきます。
2. シェーダを扱う2個のクラスを宣言する(ヘッダファイル)
Shader.hppの中に、ShaderクラスとShaderProgramクラスの2個のクラスを宣言します。Shaderクラスは頂点シェーダまたはフラグメント・シェーダなどの、ひとつひとつのシェーダを表すクラスで、ShaderProgramクラスはそれらを組み合わせて使うためのクラスです。
#ifndef Shader_hpp
#define Shader_hpp
#include <OpenGL/OpenGL.h>
#include <OpenGL/gl3.h>
#include <string>
class Shader
{
private:
GLuint shader;
public:
Shader(GLenum shaderType, const std::string& filename);
~Shader();
public:
GLuint GetHandle() const;
};
class ShaderProgram
{
private:
GLuint program;
Shader *vshader;
Shader *fshader;
public:
ShaderProgram(const std::string& vshName, const std::string& fshName);
~ShaderProgram();
public:
void Use();
};
#endif /* Shader_hpp */
OpenGLでは、シェーダを操作するためにも、シェーダを組み合わせた「プログラム」と呼ばれるものを操作するためにも、GLuint型のハンドルを使います。そのため、ShaderクラスにもShaderProgramクラスにも、両方にGLuint型の変数を用意しています。
3. シェーダを扱う2個のクラスを実装する(実装ファイル)
Shader.cppを次のように実装します。まずは前半だけ抜粋して、Shaderクラスの実装を見てみましょう。前回書いたStringSupport.hppを先頭でインクルードして、文字列処理を簡単に書けるようにしていることに注意して、プログラムを読んでください。
なお、この記事中のソースコードでは、基本的な処理の流れを説明するために、エラー処理はすべて省いています。記事の最後に完成したプロジェクトを置いていますので、エラー処理を含めた全体のソースコードはそちらを参照してください。
#include "Shader.hpp"
#include "StringSupport.hpp"
Shader::Shader(GLenum shaderType, const std::string& filename)
{
shader = glCreateShader(shaderType);
std::string sourceStr = ReadTextFile(filename);
const GLchar *source = sourceStr.c_str();
glShaderSource(shader, 1, &source, NULL);
glCompileShader(shader);
/* コンパイルに失敗した場合、ここでコンパイル時のエラー内容を表示する。 */
}
Shader::~Shader()
{
glDeleteShader(shader);
}
GLuint Shader::GetHandle() const
{
return shader;
}
シェーダを操作するためのハンドルは、glCreateShader()
関数を使うことで作成できます。この関数に渡す引数は、コンパイルする対象が頂点シェーダであればGL_VERTEX_SHADER
、フラグメント・シェーダであればGL_FRAGMENT_SHADER
です。
そしてReadTextFile()
関数にファイル名を指定して、シェーダのソースコードを読み込みます。ReadTextFile()
関数は、前回の記事で追加した関数のひとつで、指定した名前のファイルからすべてのデータをC++文字列として読み込みます。こうして読み込んだC++文字列からC言語文字列のポインタを取得し、そのポインタをGLchar型のポインタとして参照したあと、さらにそのポインタのポインタを渡してハンドルと一緒にglShaderSource()
関数を呼び出すことで、コンパイル対象となるソース文字列を設定できます。最後にハンドルを渡してglCompileShader()
関数を呼び出すことで、シェーダのコンパイルが行われます。
前述の通り、この記事ではエラー処理を省略してソースコードを掲載していますが、コンパイルに失敗した場合に、ソースコードのどこに問題があったのかを表示する機能は必須です。その辺りの処理は、「まとめ」の部分に掲載しているプロジェクトをダウンロードして、全文を参照してください。
なお、デストラクタでglDelegateShader()
関数を使ってハンドルを削除している部分も、忘れず書いておく必要があります。
Shader.cppの後半は、次の通りです。ここでもエラー処理は省略しています。
ShaderProgram::ShaderProgram(const std::string& vshName, const std::string& fshName)
{
vshader = new Shader(GL_VERTEX_SHADER, vshName);
fshader = new Shader(GL_FRAGMENT_SHADER, fshName);
program = glCreateProgram();
glAttachShader(program, vshader->GetHandle());
glAttachShader(program, fshader->GetHandle());
glLinkProgram(program);
}
ShaderProgram::~ShaderProgram()
{
glDeleteProgram(program);
delete vshader;
delete fshader;
}
void ShaderProgram::Use()
{
glUseProgram(program);
}
ShaderProgramクラスのコンストラクタでは、頂点シェーダとフラグメント・シェーダの両方の名前を受け取って、それぞれに対応したShaderクラスのインスタンスをnewしています。なお、将来的には頂点シェーダとフラグメント・シェーダを別々にコンパイルしておいた方が良いケースも出てくると思いますが、ここでは簡単のために、両方をまとめて指定するコンストラクタだけを用意しています。
両方のシェーダのコンパイルが完了したら、glCreateProgram()
関数を使って、シェーダをまとめて扱う「プログラム」を操作するためのハンドルを作成します。
そしてglAttachShader()
関数を、「プログラム」のハンドルとシェーダのハンドルを両方渡して呼び出すことで、「プログラム」にシェーダを割り当てることができます。頂点シェーダとフラグメント・シェーダの両方のシェーダを割り当てたら、最後にglLinkProgram()
関数を呼び出して、「プログラム」のリンク作業を完了させます。
こうして完成した「プログラム」は、Use()
関数の実装に使用しているように、glUseProgram()
関数を使用することで、OpenGLの描画を行うために利用が開始されます。
なお、デストラクタでglDelegateProgram()
関数を使ってハンドルを削除して、2つのシェーダをdeleteしているクリーンアップの部分も、忘れず書いておく必要があります。
4. シェーダのソースコードを用意する
それでは、頂点シェーダとフラグメント・シェーダのソースコードを書いていきましょう。
まずは「Resources」グループを右クリックして「New File…」を実行します。
そして「macOS」の「Other」グループの中から「Empty」を選んで、「Next」ボタンを押します。
このファイルに「myshader.vsh」という名前を付けて保存しましょう。頂点シェーダのソースコードを書くためのファイルです。
同じ操作を繰り返して、同じく「Empty」のファイルを「myshader.fsh」という名前で作成します。フラグメント・シェーダのソースコードを書くためのファイルです。
なお、シェーダのソースコードは文字列として読み込まれるだけですので、これらのファイルの拡張子は「.vsh」と「.fsh」である必要はありません。「.vert」と「.frag」などとしている例も見かけます。
5. 頂点シェーダのソースコードを書く
それでは myshader.vsh に頂点シェーダのコードを書きます。頂点シェーダの基本的な役割は、与えられた座標が画面上のどこに表示されるかを計算して、その変換後の座標を「gl_Position」変数に格納することです。
#version 410
layout (location=0) in vec3 vertex_pos;
layout (location=1) in vec4 vertex_color;
out vec4 color;
void main()
{
gl_Position = vec4(vertex_pos, 1.0);
color = vertex_color;
}
OpenGLのシェーダは、GLSLというシェーダ専用の言語を使って記述します。
頂点シェーダのGLSLプログラムの1行目では、#version
というキーワードの後ろに数字を書いて、GLSLのバージョンを指定します。
Appleが公開している「OpenGL および OpenCL グラフィックスを扱う Mac コンピュータ」という記事を参照すると、2012年以降に発売されているほとんどのMacでは、OpenGLのバージョンもGLSLのバージョンも、ともに4.1のバージョンがサポートされていると考えて良さそうです。
ということで、この記事では、GLSLのバージョンを「#version 410
」と指定したいと思います。
バージョン指定の次の2行は、0番目のデータとして頂点の座標を3次元ベクトルで、1番目のデータとして頂点の色情報を4次元ベクトルとして受け取るという指定です。これらの0番目と1番目のインデックスを指定してデータを渡すOpenGLの命令については、「7. シェーダを利用するコードを書く」で解説しています。
その次の「out」から始まる行では、頂点シェーダからフラグメント・シェーダに渡す色情報を、4次元ベクトルの「color」という名前で用意しています。このout変数の名前は、フラグメント・シェーダのin変数の名前と一致している必要があります。頂点シェーダで用意されているout変数が、フラグメント・シェーダのin変数として用意されていない場合、「プログラム」のリンク時にエラーが発生します。
この先の記事では、4x4行列を使ってカメラ特性を指定しながら座標変換を行う方法も解説しますが、今は与えられた座標をそのままその位置に表示することにします。gl_Position変数の方はvec4ですので、w=1.0の値を追加してvec4型に変換した上で、0番目のデータとして与えられた座標をgl_Position変数に代入します。
行列による座標変換を行わない現時点でのgl_Positionの座標系は、画面中央が原点で、X,Yともに-1.0〜1.0の値を取り、X軸は右方向に、Y軸は上方向に伸びている、次の図のような座標系になっています。
ちなみに、X軸とY軸だけでなく、Z軸も-1.0〜1.0の値を取るようになっていて、Z値が-1.0を下回るフラグメントや1.0を上回るフラグメントは画面に描画されません。このように、GLSLが扱うデフォルトの画面は、X軸・Y軸・Z軸における値が-1.0〜1.0となる、平行投影のプロジェクション環境であると言えます。
6. フラグメント・シェーダのソースコードを書く
頂点シェーダに続いて、myshader.fsh にフラグメント・シェーダのコードを書きます。フラグメント・シェーダの基本的な役割は、ピクセル(フラグメント)として画面に表示される際の1つ1つの点の色を決定することです。
#version 410
in vec4 color;
layout (location=0) out vec4 frag_color;
void main()
{
frag_color = color;
}
その役割から、フラグメント・シェーダには通常、1つのout変数を用意して、この変数にピクセルの色を格納します。out変数の名前は何でも良いのですが、ここでは「frag_color」という名前にしました(試しに「hoge_color」などの適当な名前に変えて実行してみてください。問題なくコンパイルもリンクもできて、動作も変わりません。)。
また上述の通り、頂点シェーダに用意したout変数と同じ名前のin変数も必要となります。
ここでは、頂点シェーダから渡された色情報を、フラグメント・シェーダが出力する色としてそのまま出力します。
7. VBOとVAOについて
現代的なOpenGLの描画では、VBOとVAOという2つのOpenGLオブジェクトを使って描画を行います。基本的な流れは以下の通りです。
- VBOを使って、GPU上にメモリバッファを確保する。
- VBOで確保したメモリバッファにデータを転送しておく。
- VAOを使って、VBO上のデータをどのように区切るかを指定する。
- 描画命令を実行すると、VAOの設定に従ってVBOのデータが頂点データにストリームされる。
8. シェーダを利用するコードを書く
それでは最後に、シェーダにデータを送信してレンダリングを行うコードを、Game.hppとGame.cppに書いていきましょう。
Game.hppでは、Shader.hppをインクルードして、ShaderProgramのポインタと、VAOとVBOというOpenGLオブジェクトを操作するためのハンドル変数を2個用意します。
#include <OpenGL/OpenGL.h>
#include <OpenGL/gl3.h>
#include "Time.hpp"
#include "Input.hpp"
#include "Shader.hpp"
class Game
{
private:
ShaderProgram *program;
GLuint vbo;
GLuint vao;
/* 以下、省略 */
};
Gameクラスのコンストラクタは次のように実装します。
Game::Game()
{
program = new ShaderProgram("myshader.vsh", "myshader.fsh");
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
GLfloat data[] = {
-0.5f, -0.5f, 0.0f,
1.0f, 0.0f, 0.0f, 1.0f,
0.5f, -0.5f, 0.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 0.5f, 0.0f,
0.0f, 0.0f, 1.0f, 1.0f,
};
glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * 7 * 3, data, GL_STATIC_DRAW);
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 7, (GLfloat *)0);
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 7, ((GLfloat *)0)+3);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
}
まずShaderProgramをnewして、頂点シェーダとフラグメント・シェーダをコンパイルします。
次にglGenBuffers()
関数を使ってVBOを作成します。VBO (Vertex Buffer Object) というのは、Buffer Objectを頂点シェーダに渡すデータ配列を格納するために使う時の呼び名です。glBindBuffer()
関数を使ってGL_ARRAY_BUFFERを引数に指定することで、作成したBuffer Objectを頂点シェーダに渡すデータ配列を格納するために使用することを宣言でき、VBOとして利用できるようになります。VBOに対してglBufferData()
関数を使うことで、サイズを指定してGPU上のバッファを確保し、また同時にデータのポインタを渡すことでCPU上のデータをGPU上に転送することができます。
glBufferData()
関数の使い方しだいで、バッファの確保とデータの転送を別々に行うこともできます。また、複数のVBOを用意して、頂点の座標データと色情報のデータを別々のVBOで管理することもできますが、今回のサンプルのように、座標データと色情報データを交互に配置することもできます。
glGenVertexArrays()
関数を使ってVAOを作成し、glBindVertexArray()
関数を使ってOpenGLのコンテキストにバインドすることで、以降の頂点指定関係の状態変化がすべてVAOに格納されるようになります。glVertexAttribPointer()
関数を使うことで、第1引数に指定したインデックスの頂点属性に対して、現在バインドされているVBOを参照するように設定できます。この時、第3引数で指定した型の数値が第2引数の個数ずつ消費されることも同時に指定しています(7章の図を参考にしてください)。また今回は、頂点の座標データと色情報のデータを交互に配置していますので、座標データの先頭から7個ずれたところにまた別の座標データがあることを、第4引数で指定しています。
glGenVertexArrays()
関数の第5引数は、glVertexAttribPointer()
関数のリファレンスを見ると「GLvoid *」のポインタ型になっていますが、いわゆる変数のアドレスとしてのポインタを渡すのではなく、VBOに格納されているデータの先頭からどれだけずらした位置からデータを参照し始めるのかを指定します。今回はインデックス0番の頂点属性に対して頂点の座標データを渡し、インデックス1番の頂点属性に対して頂点の色情報データを渡すことにしているので(頂点シェーダの「location=」の指定を見てください)、インデックス0番の頂点属性に対しては「0」をポインタにキャストしたものを、インデックス1番の頂点属性に対しては「0」をGLfloatのポインタ型にキャストしたものから3個ずらしたものを渡しています。
デストラクタの実装は次の通りです。デストラクタでは、シェーダをdeleteするのと、コンストラクタで作成したVBOとVAOをバインドを解除してから削除するのを忘れないでください。
Game::~Game()
{
delete program;
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glDeleteBuffers(1, &vbo);
glDeleteVertexArrays(1, &vao);
}
Render()
関数の実装は次のとおりです。
void Game::Render()
{
program->Use();
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glBindVertexArray(vao);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glDrawArrays(GL_TRIANGLES, 0, 3);
}
1行目でShaderProgramクラスのUse()関数を使って(さらにその中でglUseProgram()
関数を呼び出すことで)、レンダリングに使用するシェーダをセットします。
glBindVertexArray()
関数を使ってVAOをバインドして、glEnableVertexAttribArray()
関数を使って0番目と1番目の頂点属性を利用することを指定したら、glDrawArray()
関数を使ってレンダリングを開始します。glDrawArray()
関数の第1引数に指定したGL_TRIANGLESで、VBOからストリームされるデータを順番に3個ずつ消費して、三角形を描画していくことを指定します。
なお、コンストラクタでVBOをバインドした後にVAOをバインドしてglGenVertexArrays()
関数を呼び出したことによって、既にVAOからVBOを参照していますので(厳密にはVAOの中の各頂点属性からVBOを参照します。頂点属性ごとに別個のVBOを参照できます)、Render()
関数で改めてVBOをバインドし直す必要はありません。
また、glDrawArray()
関数を実行したことによって消費されるデータは、常にVBOからストリームされます。そのため、glDrawArray()
関数を実行するより前にVBOが削除されていたりするとエラーが発生しますので、描画を行っている間はVBOを解放してしまわないように注意してください。
9. ビューポートの調整
ここで、MyGLView.mmのrender
メソッドに、OpenGLの画面のどの範囲を描画対象としてマッピングするのかを指定するために、glViewport()
関数の呼び出しを追加しておきます。これまでは画面を一色でクリアするだけだったのでglViewport()
関数によるマッピングの指定が不要でしたが、今回からは座標を指定して図形を描画するようになったので、このビューポートの指定が必要になってきます。
まずMyGLViewクラスのインスタンス変数として、ビューポートのサイズを格納しておくためのviewportRect
変数を追加します。この変数の内容は、ウィンドウのサイズが変更される度に更新されるようにあとで調整します。
@implementation MyGLView {
...
NSRect viewportRect;
}
render
メソッドの実装は次のとおりです。viewportRect
変数に格納しておいた値を使って、glViewport()
関数を呼び出します。
- (void)render
{
Input::Update();
[glContext lock];
[glContext makeCurrentContext];
glViewport(viewportRect.origin.x, viewportRect.origin.y, viewportRect.size.width, viewportRect.size.height);
game->Render();
[glContext flushBuffer];
[glContext unlock];
Time::Update();
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
[self.window setTitle:[NSString stringWithFormat:@"Game (%.2f fps)", Time::fps]];
}];
}
ウィンドウのリサイズが起きるのは、アプリケーションの起動時(ウィンドウサイズが確定したこともリサイズとみなされます)、フルスクリーンモードになった時、フルスクリーンモードが解除された時の3つのタイミングです。このタイミングでウィンドウ・デリゲートのwindowDidResize:
メソッドが呼ばれますので、MyGLViewクラスのresetViewportSize
メソッドが呼ばれるようにします。
また、ウィンドウのサイズが変化している時にディスプレイ・リンクによる画面更新があると、画面描画が正常にできない場合がありますので、フルスクリーンモードに入る時と、フルスクリーンモードから抜ける時に、一時的にディスプレイ・リンクの実行が中断されるように、MyGLViewクラスのpauseDisplayLink
メソッドとrestartDisplayLink
メソッドが呼ばれるようにもしておきます。
#import "WindowController.h"
#import "MyGLView.h"
...
@implementation WindowController
...
- (void)windowWillEnterFullScreen:(NSNotification *)notification
{
[[MyGLView sharedInstance] pauseDisplayLink];
}
- (void)windowDidEnterFullScreen:(NSNotification *)notification
{
[[MyGLView sharedInstance] restartDisplayLink];
}
- (void)windowWillExitFullScreen:(NSNotification *)notification
{
[[MyGLView sharedInstance] pauseDisplayLink];
}
- (void)windowDidExitFullScreen:(NSNotification *)notification
{
[[MyGLView sharedInstance] restartDisplayLink];
}
- (void)windowDidResize:(NSNotification *)notification
{
[[MyGLView sharedInstance] resetViewportSize];
}
@end
これに対応した、MyGLView.hのメソッド宣言は次のとおりです。
@interface MyGLView : NSOpenGLView
...
- (void)pauseDisplayLink;
- (void)restartDisplayLink;
- (void)resetViewportSize;
@end
MyGLView.mmの実装は次のとおりです。
@implementation MyGLView
...
- (void)resetViewportSize
{
NSSize frameSize = [self frame].size;
frameSize = [self convertSizeToBacking:frameSize];
const float kBaseAspect = 4.0f / 3.0f;
if (frameSize.width / frameSize.height > kBaseAspect) {
int width = int(frameSize.height * kBaseAspect);
viewportRect.origin.x = (frameSize.width - width) / 2;
viewportRect.origin.y = 0;
viewportRect.size.width = width;
viewportRect.size.height = frameSize.height;
} else {
int height = int(frameSize.width / kBaseAspect);
viewportRect.origin.x = 0;
viewportRect.origin.y = (frameSize.height - height) / 2;
viewportRect.size.width = frameSize.width;
viewportRect.size.height = height;
}
size = self.bounds.size;
}
- (void)pauseDisplayLink
{
CVDisplayLinkStop(displayLink);
}
- (void)restartDisplayLink
{
CVDisplayLinkStart(displayLink);
}
@end
resetViewportSize
メソッドでは、ビューポートのサイズを計算します。convertSizeToBacking:
メソッドを呼び出すことによって、Retina環境で実行された場合にサイズが2倍になるように調整されます。なお、このメソッドはウィンドウ・デリゲートのメソッドから呼び出されますが、convertSizeToBacking:
メソッドはメインスレッド上でしか実行が許されていないので注意が必要です。
フルスクリーンモードにおいて、Thunderboltディスプレイなどでは縦横比が1.7になったりすることがありますので、そのままビューポートを設定すると横長に表示されてしまいます。第2回の記事で、ウィンドウのサイズを640x480ポイントに設定する(縦横比は4/3=1.3)にしていますので、これに合わせて、横長の場合は横方向にセンタリングし、縦長の場合は縦方向にセンタリングするようにglViewport()
関数に渡す値を調整しています。
10. まとめ
さあ、ここまででようやくシェーダを使ったOpenGLの描画がセットアップできましたので、実行してみましょう。次のようにカラフルな三角形が画面に描画されます。
VAOの設定に従ってVBOのバッファから頂点シェーダにデータがストリームされ、ラスタライザで線形補間された頂点データがフラグメントシェーダに渡されて、最終的なレンダリングが行われるというイメージが理解していただけるでしょうか。
なお、glDrawArrays()
関数の第1引数を「GL_TRIANGLES」から「GL_LINE_LOOP」に変えてみると、同じ3個の頂点データを使用して、閉じた線が描画されるようになります。
今回は解説が長めになりましたが、シェーダのソースコードも、Game.hppとGame.cppに追加したコードも短めですので、実際のプログラムを覗いていただく方が分かりやすいかもしれません。
ここまでのプロジェクト:MyGLGame_step2-1.zip