iPhone で 3D CG! OpenGL ES を使ってみよう
最近 iPhone を買った勢いで iPhone プログラミングを始めました。これまでも iPod touch があったのでその気になればできたのですが、 Objective-C や Cocoa を覚えるのが面倒くさくて手を出していませんでした。しかし、 iPhone を使っているうちに面白い題材に気付いたのです。そう、 OpenGL です。
実は私は数年前まではゲーム開発をしていまして、 3D CG には少なからず興味があります。さらに OpenGL はC言語ベースのクロスプラットフォームな API なので、 Mac OS X 特有のアーキテクチャにほとんど触れずに開発ができます。そんなこんなで、無謀にも Cocoa を差し置いて OpenGL プログラミングを始めたわけです。まあ、 OpenGL だけでなにをするんだという話もありますが、マルチタッチや加速度センサーの取得あたりを我慢して Objective-C で実装すれば、そこそこ面白いものが作れるかな、と思っています。
ということで、本日はこれまで調べたことをまとめて、 iPhone での OpenGL プログラミングの始め方をご紹介します。 Xcode のテンプレートから始めて、簡単な図形の表示、ライティング、そしてテクスチャーマッピングまでを扱っていますので、 iPhone 上の 3D CG に興味のある方は、参考にしてください。
プロジェクトを作成する
まずは Xcode でプロジェクトを作成しましょう。 iPhone アプリの開発環境はすでに整っていることを前提にしますので、 gihyo.jp の記事などを参考にしてセットアップしてください。 iPhone OS 3.0 でも基本的なセットアップ手順は変わっていないようです。
Xcode を起動して、メニューから [ファイル]-[新規プロジェクト] を選択します。すると、新規プロジェクトのダイアログが開きますので、左のリストで iPhone OS の Application を選び、右のアイコンリストから「OpenGL ES Application」を選択します。
この状態で「選択...」ボタンをクリックすると、プロジェクトの名前と保存場所を尋ねてきますので、適当に指定して「保存」をクリックしてください。保存場所はデスクトップなどで大丈夫です。
新規プロジェクトが作成されると、 Xcode のウインドウが開いてプロジェクトが読み込まれます。この状態で、ツールバーの「ビルドして実行」をクリックしてみましょう。プロジェクトがビルドされ、 iPhone エミュレータが起動して以下のような画面が表示されるはずです(画像は iPhone OS 2.x の頃のもの)。
すでにバッチリ動いちゃってますね。 3D CG の API はえてして初期化が面倒なので、基本的な初期化コードが自動生成されるのはたいへんありがたい。ただ、現在は単純に四角形が動いているだけで、これなら別に OpenGL を使うまでもありませんよね。きちんと三次元物体が表示できるように拡張していきましょう。
なお、以下でご紹介している変更をすべて施したファイルがこちらにあります。まとめてダウンロードするには、 Subversion で以下のようにして取得できます。
svn co http://webos-goodies.googlecode.com/svn/trunk/blog/articles/getting_started_with_opengl_on_iphone
手っ取り早く動かしてみたいというときにご利用ください。
OpenGL ES 2.0 対応を無効にする
iPhone OS 3.0 以降、「OpenGL ES Application」のサンプルコードは OpenGL ES 1.1 と OpenGL ES 2.0 の両方に対応するコードを出力します。しかし、両者はあまり互換性がなく、かつ iPhone 3G 以前の機種では OpenGL ES 2.0 は動作しないので、実際のアプリケーションは OpenGL ES 1.1 のみ実装することがほとんどでしょう。そのようなわけで、ここでも OpenGL ES 2.0 対応コードは無効にしてしまいます。
方法は簡単で、 EAGLView.m の initWithCoder メソッドにある以下の行をコメントアウトするだけです。
renderer = [[ES2Renderer alloc] init];
これで iPhone 3GS でも OpenGL ES 1.1 のコードが実行されるようになります。 OpenGL ES 2.0 向けのコード自体はバイナリに残ってしまいますが、サンプルということでご容赦ください。
深度バッファを有効にする
深度バッファとは、物体が重なったときに前後関係を正しく処理する、いわゆる隠面削除を自動処理するための機能です。ポリゴン(三角形)を描画する際にピクセル単位で視点からの距離(深度)を保存しておき、後に同じ場所にポリゴンを描画する際は保存されている深度より遠いピクセルを描画しないようにすることで、前後関係を正しく保ちます。この深度を保存するための領域を「深度バッファ」と呼ぶわけです。
深度バッファを作成するため、 ES1Renderer.m の先頭にある #import "ES1Renderer.h" の直後に、以下のコードを挿入します。
// 深度バッファのハンドルを格納するグローバル関数 static GLuint depthBuffer = 0; static void createDepthBuffer(GLuint screenWidth, GLuint screenHeight) { if(depthBuffer) { glDeleteRenderbuffersOES(1, &depthBuffer); depthBuffer = 0; } glGenRenderbuffersOES(1, &depthBuffer); glBindRenderbufferOES(GL_RENDERBUFFER_OES, depthBuffer); glRenderbufferStorageOES(GL_RENDERBUFFER_OES, GL_DEPTH_COMPONENT16_OES, screenWidth, screenHeight); glFramebufferRenderbufferOES(GL_FRAMEBUFFER_OES, GL_DEPTH_ATTACHMENT_OES, GL_RENDERBUFFER_OES, depthBuffer); glEnable(GL_DEPTH_TEST); }
createDepthBuffer 関数は、同じく ES1Renderer.m にある -resizeFromLayer メソッドから呼び出します。以下のように呼び出しコードを追加してください。
- (BOOL) resizeFromLayer:(CAEAGLLayer *)layer { // Allocate color buffer backing based on the current layer size glBindRenderbufferOES(GL_RENDERBUFFER_OES, colorRenderbuffer); [context renderbufferStorage:GL_RENDERBUFFER_OES fromDrawable:layer]; glGetRenderbufferParameterivOES(GL_RENDERBUFFER_OES, GL_RENDERBUFFER_WIDTH_OES, &backingWidth); glGetRenderbufferParameterivOES(GL_RENDERBUFFER_OES, GL_RENDERBUFFER_HEIGHT_OES, &backingHeight); // 深度バッファを(再)作成する createDepthBuffer(backingWidth, backingHeight); if (glCheckFramebufferStatusOES(GL_FRAMEBUFFER_OES) != GL_FRAMEBUFFER_COMPLETE_OES) { NSLog(@"Failed to make complete framebuffer object %x", glCheckFramebufferStatusOES(GL_FRAMEBUFFER_OES)); return NO; } return YES; }
これで起動時に深度バッファが作成され、 3 次元物体が正しく表示できるようになります。
球体の表示
深度バッファの次は、表示する物体のデータを用意します。簡単に生成できる図形ということで、球体にしましょうか。 OpenGL で物体のデータを指定する方法はいくつかあるのですが、今回は最も高速に描画できる、パック形式の VBO (Vertex Buffer Object) と IBO (Index Buffer Object) を使うことにしましょう。 DirectX で言う頂点バッファ・インデックスバッファというやつですね。
まずは球体の頂点・インデックスデータを生成して VBO/IBO にコピーする関数を作成します。先ほどの createDepthBuffer 関数の下に以下のコードを挿入します。
// VBOのハンドルを格納するグローバル変数 static GLuint sphereVBO; static GLuint sphereIBO; // 頂点のデータ構造を定義する構造体 typedef struct _Vertex { GLfloat x, y, z; GLfloat nx, ny, nz; GLfloat u, v; } Vertex; static void createSphere() { Vertex sphereVertices[17 * 9]; GLushort sphereIndices[3 * 32 * 8]; // 頂点データを生成 Vertex* vertex = sphereVertices; for(int i = 0 ; i <= 8 ; ++i) { GLfloat v = i / 8.0f; GLfloat y = cosf(M_PI * v); GLfloat r = sinf(M_PI * v); for(int j = 0 ; j <= 16 ; ++j) { GLfloat u = j / 16.0f; Vertex data = { cosf(2 * M_PI * u) * r, y, sinf(2 * M_PI * u) * r, // 座標 cosf(2 * M_PI * u) * r, y, sinf(2 * M_PI * u) * r, // 法線 u, v // UV }; *vertex++ = data; } } // インデックスデータを生成 GLushort* index = sphereIndices; for(int j = 0 ; j < 8 ; ++j) { int base = j * 17; for(int i = 0 ; i < 16 ; ++i) { *index++ = base + i; *index++ = base + i + 1; *index++ = base + i + 17; *index++ = base + i + 17; *index++ = base + i + 1; *index++ = base + i + 1 + 17; } } // VBOを作成 GLuint buffers[2]; glGenBuffers(2, buffers); sphereVBO = buffers[0]; sphereIBO = buffers[1]; // VBOを初期化し、データをコピー。 glBindBuffer(GL_ARRAY_BUFFER, sphereVBO); glBufferData(GL_ARRAY_BUFFER, sizeof(sphereVertices),sphereVertices,GL_STATIC_DRAW); glBindBuffer(GL_ARRAY_BUFFER, 0); // IBOを初期化し、データをコピー。 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, sphereIBO); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(sphereIndices), sphereIndices, GL_STATIC_DRAW); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); }
だいぶ手抜きですが、ご勘弁を。処理内容は、コメントを見ていただければ、概要はつかめると思います。 createSphere 関数は、 -init メソッドの最後で以下のように呼び出してください。
- (id) init { if (self = [super init]) { // 既存のコード ... createSphere(); } return self; }
これで物体データは用意できました。次は実際に描画する部分のコードです。 createSphere 関数の下に、以下のコードを挿入してください。
static void drawScene(GLfloat screenWidth, GLfloat screenHeight) { // 画面をクリア glViewport(0, 0, screenWidth, screenHeight); glClearColor(0.3f, 0.3f, 0.3f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // シーンの射影行列を設定 glMatrixMode(GL_PROJECTION); const GLfloat near = 0.1f, far = 1000.0f; const GLfloat aspect = screenWidth / screenHeight; const GLfloat width = near * tanf(M_PI * 60.0f / 180.0f / 2.0f); glLoadIdentity(); glFrustumf(-width, width, -width / aspect, width / aspect, near, far); // 球体の変換行列を設定 glMatrixMode(GL_MODELVIEW); glLoadIdentity(); glTranslatef(0.0, 0.0, -3.0); // 頂点データを設定 glEnableClientState(GL_VERTEX_ARRAY); glEnableClientState(GL_NORMAL_ARRAY); glEnableClientState(GL_TEXTURE_COORD_ARRAY); glBindBuffer(GL_ARRAY_BUFFER, sphereVBO); glVertexPointer(3, GL_FLOAT, sizeof(Vertex), 0); glNormalPointer(GL_FLOAT, sizeof(Vertex), (GLvoid*)(sizeof(GLfloat)*3)); glTexCoordPointer(2, GL_FLOAT, sizeof(Vertex), (GLvoid*)(sizeof(GLfloat)*6)); // インデックスデータを設定 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, sphereIBO); // 球体を描画 glDrawElements(GL_TRIANGLES, 3 * 32 * 8, GL_UNSIGNED_SHORT, 0); // bindを解除 glBindBuffer(GL_ARRAY_BUFFER, 0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); glBindTexture(GL_TEXTURE_2D, 0); }
これも処理内容はコメントのとおりですが、ポイントは glVertexPointer, glNormalPointer, glTexCoordPointer で Stride と Offset を指定していることです。こうすることにより、パックされた頂点データが正しくレンダリングできます。
drawScene 関数は render メソッドから呼び出すことにします。 render メソッドには四角形を表示する処理がすでに実装されていますが、それらを削除して、以下の内容に差し替えてください。
- (void) render { [EAGLContext setCurrentContext:context]; glBindFramebufferOES(GL_FRAMEBUFFER_OES, defaultFramebuffer); drawScene(backingWidth, backingHeight); glBindRenderbufferOES(GL_RENDERBUFFER_OES, colorRenderbuffer); [context presentRenderbuffer:GL_RENDERBUFFER_OES]; }
これで球体を表示するための実装が終了しました。ビルドして実行してみると・・・。
まだライティングを行っていないので真っ白で円にしか見えませんが、一応球体が表示されました。
ライティング
このままではあまりにも寂しいので、ライティングを施してみましょう。 drawScene の「シーンの射影行列を設定」というコメントの上に、以下のコードを挿入してください。
// ライトとマテリアルの設定 const GLfloat lightPos[] = { 1.0f, 1.0f, 1.0f, 0.0f }; const GLfloat lightColor[] = { 1.0f, 1.0f, 1.0f, 1.0f }; const GLfloat lightAmbient[] = { 0.0f, 0.0f, 0.0f, 1.0f }; const GLfloat diffuse[] = { 0.7f, 0.7f, 0.7f, 1.0f }; const GLfloat ambient[] = { 0.3f, 0.3f, 0.3f, 1.0f }; glMatrixMode(GL_MODELVIEW); glLoadIdentity(); glEnable(GL_LIGHTING); glEnable(GL_LIGHT0); glLightfv(GL_LIGHT0, GL_POSITION, lightPos); glLightfv(GL_LIGHT0, GL_DIFFUSE, lightColor); glLightfv(GL_LIGHT0, GL_AMBIENT, lightAmbient); glMaterialfv(GL_FRONT_AND_BACK, GL_DIFFUSE, diffuse); glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT, ambient);
処理内容については、ほとんど説明の必要はないと思います。蛇足ですが、 GL_POSITION に設定するベクトルの W 要素が 0 の時は平行光源、 1 のときは点光源になるようです。 OpenGL はこういうマニアックな仕様が多いのが難点ですね・・・。
これだけでライティングの実装は終了です。実行してみると、球体に陰影がついているのがわかります。
だいぶ 3D CG っぽくなってきましたね。
テクスチャを貼ってみる
物体にテクスチャマッピングを施すと、細かい質感を表現できます。ここでは、 Courtesy NASA/JPL-Caltech から入手した地球のテクスチャを貼り付けてみましょう。
まずはテクスチャ画像を読み込む必要がありますが、その処理をC言語で書くのはとても面倒なので、 Mac OS X の Core Graphics フレームワークを利用します。 Xcode のサイドバーで Frameworks を選択し、コンテキストメニューで [追加]-[既存のフレームワーク...] を選択します。ファイル選択ダイアログが開くので、以下のフォルダ(2 行に分けていますが、ひと続きのパスです)を選択して「追加」ボタンをクリックしてください。
/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/ iPhoneOS3.0.sdk/System/Library/Frameworks/CoreGraphics.framework
次は、表示するテクスチャの画像ファイルをプロジェクトに追加します。まず、こちらから画像ファイルをダウンロードして、 "earth.jpg" のファイル名でプロジェクトディレクトリにコピーしてください。そして Xcode のサイドバーで Resources を選択し、コンテキストメニューの [追加]-[既存のファイル] でファイルを追加すれば OK です。テクスチャ画像のサイズは 2 の累乗でかつ 1024 以下でなければならないので、別の画像を使う場合は注意してください。
これで準備が整ったので、 ES1Renderer.m にコードを追加しましょう。以下の loadTexture 関数を先ほど作った drawScene 関数の前に挿入してください。
// テクスチャのハンドルを格納するグローバル変数 static GLuint earthTexture; static void loadTexture() { // 画像を読み込み、 32bit RGBA フォーマットのデータを取得 CGImageRef image = [UIImage imageNamed:@"earth.jpg"].CGImage; NSInteger width = CGImageGetWidth(image); NSInteger height = CGImageGetHeight(image); GLubyte* bits = (GLubyte*)malloc(width * height * 4); CGContextRef textureContext = CGBitmapContextCreate(bits, width, height, 8, width * 4, CGImageGetColorSpace(image), kCGImageAlphaPremultipliedLast); CGContextDrawImage(textureContext, CGRectMake(0.0, 0.0, width, height), image); CGContextRelease(textureContext); // テクスチャを作成し、データを転送 glGenTextures(1, &earthTexture); glBindTexture(GL_TEXTURE_2D, earthTexture); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, bits); glBindTexture(GL_TEXTURE_2D, 0); free(bits); }
この関数は -init メソッドで、 createSphere 関数の次に呼び出すのが良いでしょう。
- (id) init { if (self = [super init]) { // 既存のコード ... createSphere(); loadTexture(); } return self; }
そして drawScene の glDrawElements 呼び出しの前に、以下のコードを挿入すれば完成です。
// テクスチャを設定して、双線形補完を有効にする glEnable(GL_TEXTURE_2D); glBindTexture(GL_TEXTURE_2D, earthTexture); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
これで実行してみると・・・
バッチリ、球体にテクスチャが貼られています。ただ、ちょっとライティングが暗すぎましたかね・・・(^^ゞ
アニメーションしてみる
仕上げに球体を回転させるアニメーションを実装しましょう。本来こういったアニメーションは前回の描画からの経過時間を計測して回転量などを算出すべきなのですが、ここでは固定フレームレートを前提にして手抜きしています。ご了承ください。
それでは実装ですが、 drawScene の glDrawElements 呼び出しの前に以下のコードを挿入するだけです。
// 球体を回転させる static GLfloat angle = 0.0f; angle += 1.0f; glRotatef(angle, 0.0f, 1.0f, 0.0f);
これで 1 フレームに 1 度ずつ回転するアニメーションが実現できます。実行してアニメーション確認してみてください。
ところで、このアニメーションをさせてから気付いたのですが、テクスチャの左右が逆になってますね(^^; 。これだから三次元の座標計算は面倒くさい。気になる方は頂点データの u に -1 を乗算すれば修正できます。
実機では Thumb 命令セットを無効にしましょう
最後に Tips をひとつ。 iPhone の CPU である ARM プロセッサには本来の ARM 命令セットのほかに Thumb と呼ばれる 16bit 長の命令セットがあります。コードサイズが小さくなるのでデフォルトで有効になっていますが、実はこの Thumb は浮動小数点演算が苦手です。そのため、浮動小数点演算を多用する 3D CG アプリケーションでは、 Thumb を無効にする方がパフォーマンスが良いと言われています。
Thumb を無効にするには、メインメニューの [プロジェクト]-[プロジェクト設定を編集] を選択してプロジェクト設定の編集ダイアログを開きます。そして以下のように 「Compile for Thumb」のチェックを外せば OK です(この項目は実機用にビルドするときのみ表示されます)。
ただし、これによって性能が向上するかどうかは浮動小数点演算をどれだけ使っているかによります。また、コードサイズが増えるというデメリットもあるので、最終的にはベンチマークをとるなどして判断した方がよいでしょう。
参考資料
実は、私は基本的に DirectX な人なので、 OpenGL を本格的に使うのは今回が初めてでした。そこで手っ取り早く勉強するための書籍を探したのですが、有名な赤本、青本は若干高額なうえに不要な機能の説明も多く、個人的にはイマイチでした。そんなこんなで、けっきょく購入したのが左のOpenGLの神髄という本です。
iPhone や OpenGL ES のための解説本ではありませんが、内容は偶然にも (?) かなり iPhone に適したものになっています。 glBegin / glEnd や GLUT など、 iPhone で使えない機能の解説はバッサリと省かれており、必要な知識が効率よく学べます。また、 Windows, Mac OS X, Linux での OS 依存部分に関する解説もあり、これらの環境にポートする際も参考になります。唯一、 iPhone 3GS に搭載されたプログラマブルシェーダーの解説がないのは残念ですが、まだ iPhone 3GS 専用ソフトを作るのは時期尚早なので、問題ないでしょう。
現在は日本語で OpenGL ES を解説した書籍はほとんどないようなので、おそらくこの本が最良の解説書だと思います。値段も 3,500 円と手頃でお勧めです。
その他、今回の記事を書くにあたって参考にしたサイトを以下に挙げておきます。
- iPhone Dev Center
- Apple の iPhone 開発者向けサイトです。 iPhone Reference Library のページでサイドバーから「OpenGL ES」を選べば、いくつかの資料やサンプルが入手できます。
- OpenGL SDK
- opengl.org のドキュメントサイトです。 reference pages に OpenGL の全ての関数に関する優れたリファレンスがあります。
- Khronos OpenGL ES API Registry
- OpenGL ES の仕様に関するドキュメントが集積されているページです。 iPhone 3GS は OpenGL ES 1.1 と 2.0 の双方に対応していますが、 iPhone 3G 以前で利用できるのは OpenGL ES 1.1 のみなので注意してください。とくに OpenGL ES で利用できる拡張に関してはこちらを参照するのが良いでしょう。
- PowerVR Insider
- iPhone で使われている GPU の開発元である Imagination の開発者向けサイトです。 iPhone 特有の拡張機能に関してはこちらのサイトが参考になります。
- 実践! iPhoneアプリ開発
- iPhone アプリ開発全般に関するマイコミジャーナルの解説記事です。 iPhone の開発環境を整える際に参考になります。
- iPhone OpenGL ES Tutorial Series
- iPhone の OpenGL に関するチュートリアルが多数あります。この記事を書く際にもだいぶ参考にしています(^^;
また、今回も Twitter でいろいろな方にアドバイスをいただきました。大感謝です。
以上、本日は iPhone で OpenGL を利用する方法をご紹介しました。デスクトップ並みのグラフィック機能がこんな小さな携帯端末で動くのは、なかなか感慨深いものがありますね。さらに iPhone にはマルチタッチをはじめとして、デスクトップにはないデバイスがいろいろあるので、それらを OpenGL と絡めれば、いろいろ面白いことができそうです。 iPhone ユーザーの方は、ぜひ挑戦してみてください!
2009/10/30 追記 :
マイコミジャーナルの実践! iPhoneアプリ開発に CGDataProvider を使ってテクスチャデータを読み込む方法が紹介されていました。単純に画像を読み込むだけならこちらのほうが楽そうです。ただ、 CGContext を使う方法には読み込み時に拡大・縮小などの加工ができる利点があるので、場合によって使い分けるのが良いと思います。
2009/11/17 追記 :
サンプルコードを iPhone OS 3.0 SDK のプロジェクト・テンプレートに対応させました。
詳しくはこちらの記事をどうぞ!
この記事にコメントする