OpenGL 基礎シリーズ の第 6 回です。
Hello World in OpenGL!
いよいよこのシリーズもこれで最後。実際に動作するソースコードを紹介しながらこれまで得た知識と照らしあわせていきたい。記事の最初にも書いたとおりサンプルコードは Android になるけど、Android 固有の部分を除いて OpenGL の部分に関しては他のプラットフォームでも似たような API は提供されているので (若干仕様が異なる API もあるけど) これまでに蓄えられてきた基礎知識が活きていれば Android 以外でも「こんな風に書けばいいのかな」という想像が簡単にできると思う。
ということで早速コードを紹介しつつ解説を。
MainActivity.java
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
GameSurfaceView view = new GameSurfaceView(this);
setContentView(view);
}
}
まずはアクティビティ。GameSurfaceView
という独自のビューを生成してアクティビティに配置している。
GameSurfaceView.java
public class GameSurfaceView extends GLSurfaceView {
private static final int OPENGL_ES_VERSION = 2;
public GameSurfaceView(Context context) {
super(context);
setEGLContextClientVersion(OPENGL_ES_VERSION);
setRenderer(new GameRenderer());
setRenderMode(RENDERMODE_CONTINUOUSLY);
}
}
次に GameSurfaceView
。Android で OpenGL を使った描画をするためには GLSurfaceView
というビューを継承したカスタムビューを作る。そのカスタムビューの中で独自のレンダラーをセットすることで、そのレンダラーの中で実装された関数が呼び出され、OpenGL とのやり取りができるようになる。
BufferUtil.java
public class BufferUtil {
public static FloatBuffer convert(float[] data) {
ByteBuffer bb = ByteBuffer.allocateDirect(data.length * 4);
bb.order(ByteOrder.nativeOrder());
FloatBuffer floatBuffer = bb.asFloatBuffer();
floatBuffer.put(data);
floatBuffer.position(0);
return floatBuffer;
}
public static ShortBuffer convert(short[] data) {
ByteBuffer bb = ByteBuffer.allocateDirect(data.length * 2);
bb.order(ByteOrder.nativeOrder());
ShortBuffer shortBuffer = bb.asShortBuffer();
shortBuffer.put(data);
shortBuffer.position(0);
return shortBuffer;
}
}
これは Java のプリミティブ型を GPU に転送するためにバッファ型に変換するためのユーティリティクラスで、頂点座標や頂点インデックスを GPU に転送する際に利用することになる。
さて、これまで 3 つのクラス MainActivity
と GameSurfaceView
と BufferUtil
を見てきたけど、これらは Android 固有の部分なので OpenGL とはあまり関係はない。次の GameRenderer
クラスが本丸だ。
GameRenderer.java
public class GameRenderer implements GLSurfaceView.Renderer {
public static final String sVertexShaderSource =
"uniform mat4 vpMatrix;" +
"uniform mat4 wMatrix;" +
"attribute vec3 position;" +
"void main() {" +
" gl_Position = vpMatrix * wMatrix * vec4(position, 1.0);" +
"}";
public static final String sFragmentShaderSource =
"precision mediump float;" +
"void main() {" +
" gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);" +
"}";
private int mProgramId;
private float[] mViewAndProjectionMatrix = new float[16];
private long mFrameCount = 0;
@Override
public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
GLES20.glClearColor(0f, 0f, 0f, 1f);
int vertexShader = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER);
GLES20.glShaderSource(vertexShader, sVertexShaderSource);
GLES20.glCompileShader(vertexShader);
int fragmentShader = GLES20.glCreateShader(GLES20.GL_FRAGMENT_SHADER);
GLES20.glShaderSource(fragmentShader, sFragmentShaderSource);
GLES20.glCompileShader(fragmentShader);
mProgramId = GLES20.glCreateProgram();
GLES20.glAttachShader(mProgramId, vertexShader);
GLES20.glAttachShader(mProgramId, fragmentShader);
GLES20.glLinkProgram(mProgramId);
GLES20.glUseProgram(mProgramId);
}
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
GLES20.glViewport(0, 0, width, height);
float[] projectionMatrix = new float[16];
float[] viewMatrix = new float[16];
Matrix.setLookAtM(viewMatrix, 0, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f);
Matrix.orthoM(projectionMatrix, 0, -width / 2f, width / 2f, -height / 2, height / 2, 0f, 2f);
Matrix.multiplyMM(mViewAndProjectionMatrix, 0, projectionMatrix, 0, viewMatrix, 0);
}
@Override
public void onDrawFrame(GL10 gl10) {
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
float length = 100f;
float left = -length / 2f;
float right = length / 2f;
float top = -length / 2f;
float bottom = length / 2f;
float[] vertices = new float[] {
left, top, 0f,
left, bottom, 0f,
right, bottom, 0f,
right, bottom, 0f,
right, top, 0f,
left, top, 0f
};
short[] indices = new short[] {
0, 1, 2,
3, 4, 5
};
FloatBuffer vertexBuffer = BufferUtil.convert(vertices);
ShortBuffer indexBuffer = BufferUtil.convert(indices);
float[] worldMatrix = new float[16];
Matrix.setIdentityM(worldMatrix, 0);
Matrix.rotateM(worldMatrix, 0, (float)mFrameCount / 2f, 0, 0, 1);
int attLoc1 = GLES20.glGetAttribLocation(mProgramId, "position");
int uniLoc1 = GLES20.glGetUniformLocation(mProgramId, "vpMatrix");
int uniLoc2 = GLES20.glGetUniformLocation(mProgramId, "wMatrix");
GLES20.glEnableVertexAttribArray(attLoc1);
GLES20.glVertexAttribPointer(attLoc1, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
GLES20.glUniformMatrix4fv(uniLoc1, 1, false, mViewAndProjectionMatrix, 0);
GLES20.glUniformMatrix4fv(uniLoc2, 1, false, worldMatrix, 0);
GLES20.glDrawElements(GLES20.GL_TRIANGLES, indexBuffer.capacity(), GLES20.GL_UNSIGNED_SHORT, indexBuffer);
GLES20.glDisableVertexAttribArray(attLoc1);
mFrameCount++;
}
}
突然でかいソースコードがきたのでひよったかもしれないけど、1 つずつ見ていくことにする。まずこのクラスは GLSurfaceView.Renderer
というインターフェースを実装している。このインターフェースには
onSurfaceCreated
OpenGL 用にビューが生成された時に実行されるonSurfaceChanged
ビューのサイズが変更された時に実行されるonDrawFrame
毎フレーム実行される
の 3 つのコールバックメソッドがあるのでこれらをすべて実装していく。順番的に onSurfaceCreated
> onSurfaceChanged
> onDrawFrame
> onDrawFrame
> onDrawFrame
… という風に呼ばれていく。まあこの辺もまだ Android 固有だ。OpenGL が関係してくるのは実装したそれぞれのメソッドの中身なので、次のセクションから集中していこう。
onSurfaceCreated
アプリが起動して OpenGL の準備ができて最初に実行される部分になるので、ここで色々な初期化処理を実行しておく。
画面クリア時の色の設定
GLES20.glClearColor(0f, 0f, 0f, 1f);
まず最初に実行されてるのがコレ。ゲームとか作る場合は毎フレーム、画面を一旦クリアにしてからその上に描画していく形になるんだけど、画面クリア処理をする時にクリアされる色を設定している。引数には (R, G, B, A)
の順序で範囲は 0 〜 1を指定。この場合は真っ黒を設定している。
シェーダのコンパイル
int vertexShader = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER);
GLES20.glShaderSource(vertexShader, sVertexShaderSource);
GLES20.glCompileShader(vertexShader);
int fragmentShader = GLES20.glCreateShader(GLES20.GL_FRAGMENT_SHADER);
GLES20.glShaderSource(fragmentShader, sFragmentShaderSource);
GLES20.glCompileShader(fragmentShader);
で、次はシェーダをコンパイルしている。今まで説明してきたバーテックスシェーダとフラグメントシェーダは GLSL という言語で開発するという話を最初にしたと思うけど、その GLSL で書いたコードはアプリの実行時に GPU にコンパイルさせて実行可能な状態にさせとかなきゃいけない。sVertexShaderSource
と sFragmentShaderSource
に文字列としてシェーダのソースコードが入ってるのでそれをコンパイルしてやる。シェーダの中身の説明はもう少し先でやるので、ここではとりあえずシェーダはコンパイルが必要だよ、ってことを理解しておこう。
シェーダのリンク
mProgramId = GLES20.glCreateProgram();
GLES20.glAttachShader(mProgramId, vertexShader);
GLES20.glAttachShader(mProgramId, fragmentShader);
GLES20.glLinkProgram(mProgramId);
GLES20.glUseProgram(mProgramId);
シェーダがコンパイル出来たら次にそれをプログラムにリンクさせる。このプログラムの ID は後で使うことになるのでメンバ変数に保持しておく。これで初期化処理は終わり。この辺のシェーダのコンパイルとかプログラムのリンクとかの処理は Android に限らず OpenGL を使うのであればどのプラットフォームでも同じでテンプレート的な処理なので、最初にやっておかないといけない。なのでここはそういうもんだと暗記していいかもね。
onSurfaceChanged
このコールバックには画面の幅と高さが渡ってくるので、それに関連した初期化処理をやっておくようにする。
ビューポート設定
GLES20.glViewport(0, 0, width, height);
これは ビューポート変換 をする時の幅と高さを設定する。ここでは x, y 座標に 0
を、幅と高さには width
と height
をそのまま渡しているので、Android の画面全体をビューポートとして設定していることになる。
ビュー座標変換行列と射影変換行列の生成
float[] projectionMatrix = new float[16];
float[] viewMatrix = new float[16];
Matrix.setLookAtM(viewMatrix, 0, 0f, 0f, 1f, 0f, 0f, 0f, 0f, 1f, 0f);
Matrix.orthoM(projectionMatrix, 0, -width / 2f, width / 2f, -height / 2, height / 2, 0f, 2f);
Matrix.multiplyMM(mViewAndProjectionMatrix, 0, projectionMatrix, 0, viewMatrix, 0);
次にバーテックスシェーダの座標変換の記事で扱った ビュー座標変換 と 射影変換 をするための変換行列を生成して、その 2 つを掛け合わせてまとめている。
カメラの位置は (0, 0, 1)
、カメラの注視点は (0, 0, 0)
、そしてカメラの上方向は (0, 1, 0)
になっているので、原点より少し手前の位置から原点を見ているカメラを配置していることになる。そして射影変換については、原点を中心として幅が width
、高さが height
の長方形があって、その奥行は 2
の立方体をクリッピング空間としている。
バーテックスシェーダとフラグメントシェーダ
onSurfaceCreated
と onSurfaceChanged
で初期化処理は終わったので、次は実際に OpenGL を使って描画をしているところに移っていくけど、その前にシェーダ部分を見てみる。
バーテックスシェーダ
public static final String sVertexShaderSource =
"uniform mat4 vpMatrix;" +
"uniform mat4 wMatrix;" +
"attribute vec3 position;" +
"void main() {" +
" gl_Position = vpMatrix * wMatrix * vec4(position, 1.0);" +
"}";
(すごく見難いというのはおいとこう…) 最初の 3 行に変数の定義があってその後に main 関数がある。シェーダが実行される時はこの main 関数が呼び出されるので、基本的にはこの中に処理を書いていく。変数は 種別 型 変数名
という風に定義することになっていて、この例では 3 つの変数が定義されている。
vpMatrix
ビュー座標変換行列と射影変換行列を掛けあわせた 4x4 の行列wMatrix
4x4 のワールド座標変換行列position
x, y, z 成分を持つ頂点座標
後々でてくるけど、これらの変数はアプリケーション中のメモリから OpenGL の API を介して GPU に転送される。そして main 関数の中では vpMatrix
と wMatrix
を、頂点座標である position
にかけている。つまり座標系の変換をしている。この時 x, y, z 成分しかない position
に w 成分 1
を加えていて、これは前に少し出てきた同次座標のこと。この辺の話は バーテックスシェーダの座標変換 で詳しく見ていった部分だ。テキストだと随分文量が多かったけど、コードにしてみるとこんなもん。gl_Position
というのは組み込みの変数で、この変数に座標変換した最終的な座標を代入すると、その座標が次の処理に渡されていく。
フラグメントシェーダ
public static final String sFragmentShaderSource =
"precision mediump float;" +
"void main() {" +
" gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);" +
"}";
次にフラグメントシェーダ。これもバーテックスシェーダと同じように main 関数がある。今回のフラグメントシェーダでは変数は特に使わないので定義されていない。また precision mediump float;
の部分はおまじない的なものなので無視してよい。このフラグメントシェーダでは一律赤色 vec4(1.0, 0.0, 0.0, 1.0)
を出力するようにしている。色は rgba の成分を 0 〜 1 の範囲で指定する。gl_FragColor
というのはバーテックスシェーダで出てきたのと同じで組み込みの変数になっていて、この変数に色を代入するとその色をフラグメントの色として、次の処理に渡されていく。
※ uniform
と attribute
の違いや変数の型、それから precision
については処理の本質にあまり関係ないのでここでは割愛するけど、GLSL を解説しているサイトを検索してみるといくつか見つかるのでそこで勉強してみるのもよい。
onDrawFrame
初期化が終わってシェーダの中身も見ていったので、いよいよ OpenGL を使って描画をしている中核となる部分を見ていく。この onDrawFrame
は最大 60 FPS の頻度で、つまり 1 秒間に 60 回程度実行されるもので、画面を表示している限りは何度も何度も実行される。
画面クリア
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
まず画面を初期化している。この初期化する時の色は最初に GLES20.glClearColor
で設定した色、すなわちここでは黒が使われる。要するにここでは毎フレームの一番最初に画面を真っ黒に初期化している。
頂点座標と頂点インデックスの定義
float length = 100f;
float left = -length / 2f;
float right = length / 2f;
float top = -length / 2f;
float bottom = length / 2f;
float[] vertices = new float[] {
left, top, 0f,
left, bottom, 0f,
right, bottom, 0f,
right, bottom, 0f,
right, top, 0f,
left, top, 0f
};
short[] indices = new short[] {
0, 1, 2,
3, 4, 5
};
FloatBuffer vertexBuffer = BufferUtil.convert(vertices);
ShortBuffer indexBuffer = BufferUtil.convert(indices);
次に頂点座標と頂点インデックスの定義をしている。これは原点を中心とした、幅と高さが 100
の正方形の頂点情報になっている。この辺は 頂点情報とプリミティブ での話だね。(最後の 2 行は頂点座標と頂点インデックスを GPU に送るためのバッファ型があるので自作関数を使って変換している。Android は Java のプリミティブの型のままでは GPU にデータ転送できないのでこういった変換処理が必要となる。ここも Android 特有の処理)
ワールド座標変換行列の生成
float[] worldMatrix = new float[16];
Matrix.setIdentityM(worldMatrix, 0);
Matrix.rotateM(worldMatrix, 0, (float)mFrameCount / 2f, 0, 0, 1);
ただ四角形を描画するだけでは面白く無いので描画した図形が回転するアニメーションをさせるために、回転の座標変換行列を生成している。別途現在のフレーム数を保持しておきそれを z 軸を中心とした回転角として行列を生成することで、フレームが進むごとに少しずつ回転していくアニメーションを再現できる。
シェーダ内変数へデータ転送
int attLoc1 = GLES20.glGetAttribLocation(mProgramId, "position");
int uniLoc1 = GLES20.glGetUniformLocation(mProgramId, "vpMatrix");
int uniLoc2 = GLES20.glGetUniformLocation(mProgramId, "wMatrix");
GLES20.glEnableVertexAttribArray(attLoc1);
GLES20.glVertexAttribPointer(attLoc1, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
GLES20.glUniformMatrix4fv(uniLoc1, 1, false, mViewAndProjectionMatrix, 0);
GLES20.glUniformMatrix4fv(uniLoc2, 1, false, worldMatrix, 0);
バーテックスシェーダには 3 つの変数が定義されていた。それらの変数にはアプリケーション内からデータを転送してあげる必要があるんだけど、シェーダ内の変数にデータを送信するためにはどの変数に対してデータを送信するかという識別子のようなものが必要になるので、まず最初の 3 行でシェーダ内変数の識別子を取得している。attribute
な変数は glGetAttribLocation
で、uniform
な変数は glGetUniformLocation
でそれぞれ取得できる。さらに attribute
な変数ではそれを有効化してあげる必要もあるため、次の行で glEnableVertexAttribArray
を呼び出してデータを送信可能な状態にしている。
最後の 3 行ではアプリケーション内のメモリから GPU へデータを転送するための処理。これで全ての準備は整った。
描画
GLES20.glDrawElements(GLES20.GL_TRIANGLES, indexBuffer.capacity(), GLES20.GL_UNSIGNED_SHORT, indexBuffer);
後は描画する API をコールするだけ。GL_TRIANGLES
は 描画モードの指定 で説明した部分の定数。つまりは、上で定義した vertexBuffer
を頂点座標、indexBuffer
を頂点インデックスとして、三角形を描画していく。このソースコードをコンパイルして出来上がった apk を Android 端末にインストールすると、画面中央でぐるぐる回る赤い四角形が描画される。
おまけ
おまけです。
テクスチャ
サンプルのコードではテクスチャの表示をしてなかったけど、頂点座標と同じように float
型で頂点に対応する UV 座標を定義すれば良い。それを頂点座標と同じように GPU に転送して後、バーテックスシェーダからフラグメントシェーダへ渡すようにして、フラグメントシェーダでは渡された UV 座標 (補間されている) を使ってテクスチャから取得した色を gl_FragColor
に設定する。
別途サンプルを用意するとちょっと記事が長くなりすぎるので、ここでは割愛。この辺のサンプルが欲しければネット上にたくさん転がっている。
座標変換行列を使わない?
サンプルソースコードでは回転行列とビュー座標変換行列、それから射影変換行列を生成してバーテックスシェーダ内で座標変換を行っている。でもよく考えてみよう、回転とかは考えずに単純に画面に四角形を描画するだけであれば、実は座標変換行列はなくても描画できる。バーテックスシェーダの座標変換 のところで見たように、頂点座標は最終的には x, y, z 座標がそれぞれ -1 〜 1 の範囲に収まるクリッピング座標系に変換されている必要がある。逆に言うと、最初に定義する頂点座標が直接 -1 〜 1 の範囲内にあるクリッピング座標系で定義されていれば座標変換は不要ということになる。普通はそんなわかりにくいことやらないとは思うけど、座標変換の理屈を理解していれば必ずしも座標変換行列を用意しないと描画ができないかというと、そうでもないってことがわかる。
こんな頂点座標を定義して
float length = 1f;
float left = -length / 2f;
float right = length / 2f;
float top = -length / 2f;
float bottom = length / 2f;
float[] vertices = new float[] {
left, top, 0f,
left, bottom, 0f,
right, bottom, 0f,
right, bottom, 0f,
right, top, 0f,
left, top, 0f
};
バーテックスシェーダではそのまま position
を渡してあげると
public static final String sVertexShaderSource =
"attribute vec3 position;" +
"void main() {" +
" gl_Position = vec4(position, 1.0);" +
"}";
画面の横幅、縦幅に比例した赤い四角形が表示される。
Next Stage
たかが正方形のポリゴン 1 つ表示するだけなのにこれだけの量のソースコードを書かないといけないのは正直つかれるし、こんなんだから気軽に Hello World できないし敷居が高いと言われる。でも このシリーズ を最初から読んできた人にとっては細かく分割して説明したソースコード 1 つ 1 つのやってることの意味はたぶんわかったんじゃないかなーと思う。そしてもし、これらのソースコードがやってることがわかったと言うなら、あとはどれだけでも応用は利く。一番はじめにも書いたように物事は突き詰めていけば単純だ。最初は何もわからなくても 1 つずつしっかりと理解していけば理解できないことはない。
そして何より大事なのが「知りたい」という欲求や、そこから湧き出てくる物事に対する興味。OpenGL についていろいろ書いてきたこのブログだけど、こんなのは基礎の基礎に過ぎない。他にもこのシリーズには出てきてないたくさんの概念、たとえばデプステスト、アルファブレンディング、ステンシルテスト、頂点バッファ、フレームバッファ、などなど、蓋を開けてみれば本当にたくさんのことがある。こういった概念達を知ることで、もっと素晴らしい表現ができるようになるし、もっともっと OpenGL が楽しいと思えるかもしれない。
単語だけ聞くと相変わらずどれも難しいように聞こえるけど、下地となる基礎がしっかりとしていればおのずと理解ができてくる。基礎が理解できたのであれば、天高くそびえ立つ難攻不落だった OpenGL はもはや存在しない。そこにあるのは、OpenGL といういわば高嶺の花に魅了されたあなたがこれから先あたらしいものを創りだしていくのに必要な、プログラミングをするための両手だけだ。