はじめに
これまでは頂点座標と面データだけで構成されている Stanford Bunny の単純なOBJファイルだけを使ってきたので、頂点座標と面データの読み込みだけをサポートしていました。しかしOBJファイルには、3Dモデルが複数のグループに分かれて格納されていることもありますし、法線ベクトルや頂点座標が格納されていることもあります。そういった3Dモデルを読み込むためには、しっかりとした読み込みのコードを書く必要があります。
また、これまでは3Dモデルを構成するポリゴンの法線ベクトルを、外積を使って単純に計算していましたが、スムージングという処理を加えることによって、いかにもポリゴンということが分かるカクカクした見た目を改良できます。スムージングとは、隣り合ったポリゴンの角度を考えながら、ポリゴン各頂点の法線ベクトルを傾かせることによって、角が滑らかに見えるようにする処理です。次の図は、左がスムージングなし、右がスムージングありでレンダリングした例です。
この記事では、Apple が macOS 10.11 以降の環境で標準で提供している3Dモデル読み込みのための Model I/O フレームワークを使って、より簡単に、より正確に、3Dモデルのデータを読み込む方法を解説します。Model I/O はスムージングの処理もサポートしていますので、複雑な計算を自分で行わずに、綺麗な見た目の法線ベクトルを手に入れることができます。
なお、「2-4. テクスチャを表示する」でテクスチャを読み込むために使った GLKTextureLoader クラスは macOS 10.8(2012年7月に初版リリース)および iOS 5(2011年10月に初版リリース)からサポートされていましたので、これまでの機能は2012年7月以降にマシンを購入した人(あるいはそれ以降のバージョンにアップグレードした人)に向けてリリース可能でした。それに対して、今回使用する Model I/O は macOS 10.11(2015年9月に初版リリース)および iOS 9(2015年9月に初版リリース)からサポートされているクラスですので、2015年9月以降にマシンを購入した人(あるいはそれ以降のバージョンにアップグレードした人)に向けてリリースすることになります。サポートする必要のあるデバイスが2015年9月よりも前に発売されたものである場合、この記事で解説する Model I/O を使った読み込みは使えませんので、自力でOBJファイルの読み込みを細かくサポートしたり、法線ベクトルのスムージングを実装したりする必要があります。
C++でのOBJファイルの読み込みのために、tinyobjloaderというMITライセンスの実装が公開され、広く使われているようですが、法線ベクトルの生成やスムージングは自分で実装しなければならないようです。
1. Model I/Oフレームワークを使う準備
まず Model I/O フレームワークを使うための準備をします。
Model I/O は、iOS 9.0およびmacOS 10.11からサポートされている、3Dモデルのデータを読み込むためのフレームワークです。Appleのデバイス上で3Dモデルを扱うフレームワークは、OpenGL, Metal, Scene Kit の3種類がありますが、Model I/O はそのすべてに共通して使うことができるように設計されています。
実行ファイルに Model I/O フレームワークがリンクされるように、ウィンドウ左側のプロジェクト・ナビゲータからプロジェクト名を選択し、「TARGETS」のプロジェクト名を選択し、「Linked Frameworks and Libraries」の [+] ボタンを押します。
そして「ModelIO.framework」を選択して、[Add] ボタンを押して追加します。
Model I/O フレームワークが追加されました。
2. Meshクラスのファイルを作成する
3Dモデルを読み込んで、描画の機能も提供するための Mesh
クラスを作成しましょう。
「GameLibrary」グループの「StringSupport.mm」ファイルを右クリックし、出てくるメニューから、「New File...」を選択します。
「macOS」の「C++」を選択して、「Next」ボタンを押します。
「Name」に「Mesh」と入力し、「Next」ボタンを押します。
最後に「Create」ボタンを押すと、Mesh.hpp ファイルと Mesh.cpp ファイルが出来上がります。
Model I/O の API は Objective-C からアクセスしますので、Mesh.cpp ファイルの拡張子を Mesh.mm に変更して、Objective-C++ でコンパイルされるようにしておきます。
3. Meshクラスを実装する
Mesh.hpp にMeshクラスの宣言を書きます。
//
// Mesh.hpp
// MyGLGame
//
// Created by numata on 2018/01/24.
// Copyright © 2018年 Satoshi Numata. All rights reserved.
//
#ifndef Mesh_hpp
#define Mesh_hpp
#include <OpenGL/OpenGL.h>
#include <OpenGL/gl3.h>
#include <GLKit/GLKMath.h>
#include <string>
class Mesh
{
GLuint vbo;
GLuint vao;
GLuint ibo;
GLenum indexType;
GLsizei indexCount;
public:
Mesh(const std::string& filename, const GLKVector4& color = {1.0f, 1.0f, 1.0f, 1.0f}, float smoothingLevel = 1.0f);
~Mesh();
public:
void Draw() const;
};
#endif /* Mesh_hpp */
Mesh クラスのコンストラクタには、モデルファイルのファイル名、モデルの色、スムージングの有無を指定するための引数を用意しています。
また、1つのメッシュにつき、VBO, VAO, IBO を1セット用意して、その上にデータを読み込むようにします。そのため、VBO, VAO, IBO のハンドラを表す GLuint
型の変数 vbo
, vao
, ibo
をMeshクラスのインスタンス変数として用意しました(それに伴って、Gameクラスの実装からは、これらの変数を取り除きます)。そして、読み込んだインデックスデータの型を表す indexType
変数と、インデックスの個数を格納しておくための indexCount
変数を用意しています。
次に、Mesh.mm ファイルに書く Mesh クラスの実装コードを示します。
//
// Mesh.mm
// MyGLGame
//
// Created by numata on 2018/01/24.
// Copyright © 2018年 Satoshi Numata. All rights reserved.
//
#include "Mesh.hpp"
#include "StringSupport.hpp"
#include <vector>
#import <ModelIO/ModelIO.h>
struct VertexData
{
GLKVector3 pos;
GLKVector3 normal;
GLKVector4 color;
};
static int MDLMesh_CountVertexBuffers(MDLMesh *mesh)
{
NSArray<id<MDLMeshBuffer>> *buffers = mesh.vertexBuffers;
int ret = 0;
for (id<MDLMeshBuffer> buffer in buffers) {
if ([buffer isKindOfClass:[NSNull class]]) {
break;
}
ret++;
}
return ret;
}
Mesh::Mesh(const std::string& filename, const GLKVector4& color, float smoothingLevel)
{
assert(smoothingLevel >= 0.0f && smoothingLevel <= 1.0f);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &ibo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
NSString *theFilename = [NSString stringWithCString:filename.c_str() encoding:NSUTF8StringEncoding];
NSURL *theURL = [[NSBundle mainBundle] URLForResource:[theFilename stringByDeletingPathExtension]
withExtension:theFilename.pathExtension];
if (!theURL) {
throw GameError("Cannot locate a mesh file with name: %s", filename.c_str());
}
MDLAsset *asset = [[MDLAsset alloc] initWithURL:theURL];
NSArray *assets = [asset childObjectsOfClass:[MDLMesh class]];
if (assets.count > 0) {
MDLMesh *mesh = assets[0];
if (MDLMesh_CountVertexBuffers(mesh) == 1) {
[mesh addNormalsWithAttributeNamed:MDLVertexAttributeNormal
creaseThreshold:(1.0f - smoothingLevel)];
}
std::vector<VertexData> vertices;
MDLVertexAttributeData *attrPos = [mesh vertexAttributeDataForAttributeNamed:MDLVertexAttributePosition];
MDLVertexAttributeData *attrNormal = [mesh vertexAttributeDataForAttributeNamed:MDLVertexAttributeNormal];
float *p0 = (float *)attrPos.dataStart;
float *p1 = (float *)attrNormal.dataStart;
for (NSUInteger i = 0; i < mesh.vertexCount; i++) {
VertexData v;
v.pos.x = *(p0);
v.pos.y = *(p0 + 1);
v.pos.z = *(p0 + 2);
v.normal.x = *(p1);
v.normal.y = *(p1 + 1);
v.normal.z = *(p1 + 2);
v.color = color;
vertices.push_back(v);
p0 += attrPos.stride / sizeof(float);
p1 += attrNormal.stride / sizeof(float);
}
for (MDLSubmesh *submesh in mesh.submeshes) {
if (submesh.geometryType != MDLGeometryTypeTriangles) {
throw GameError("Mesh data should be composed of triangles");
}
id<MDLMeshBuffer> indexBuffer = submesh.indexBuffer;
GLsizeiptr indexDataSize;
if (submesh.indexType == MDLIndexBitDepthUInt8) {
indexType = GL_UNSIGNED_BYTE;
indexCount = (GLsizei)indexBuffer.length;
indexDataSize = sizeof(GLubyte);
} else if (submesh.indexType == MDLIndexBitDepthUInt16) {
indexType = GL_UNSIGNED_SHORT;
indexCount = (GLsizei)(indexBuffer.length / 2);
indexDataSize = sizeof(GLushort);
} else if (submesh.indexType == MDLIndexBitDepthUInt32) {
indexType = GL_UNSIGNED_INT;
indexCount = (GLsizei)(indexBuffer.length / 4);
indexDataSize = sizeof(GLuint);
} else {
throw GameError("Mesh data type is invalid");
}
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indexDataSize * indexCount, indexBuffer.map.bytes, GL_STATIC_DRAW);
}
glBufferData(GL_ARRAY_BUFFER, sizeof(VertexData) * vertices.size(), &vertices[0], GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(VertexData), &((VertexData *)0)->pos);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(VertexData), &((VertexData *)0)->normal);
glVertexAttribPointer(2, 4, GL_FLOAT, GL_FALSE, sizeof(VertexData), &((VertexData *)0)->color);
} else {
throw GameError("Failed to make an asset from a mesh file: %s", filename.c_str());
}
}
Mesh::~Mesh()
{
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glDeleteBuffers(1, &ibo);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &vbo);
}
void Mesh::Draw() const
{
glBindVertexArray(vao);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);
glDrawElements(GL_TRIANGLES, indexCount, indexType, (void *)0);
}
まず、Model I/O フレームワークを使うために、先頭で「#import <ModelIO/ModelIO.h>
」として Model I/O のヘッダファイルを読み込んでいます。
次に、頂点データを表す VertexData
構造体の宣言を、Game.hpp ファイルから Mesh.mm ファイルに移動させています。頂点データをいったん VBO 上にアップロードし、VAO 上にその構造を書き込んだら、VertexData
構造体を外部から参照する必要はなくなります。
Mesh クラスのコンストラクタでは、まず assert()
関数を使って smoothingLevel
の値の範囲をチェックします。この値が 0.0
ならスムージングなし、1.0
ならスムージングありと考えます。そして glGen*()
関数と glBind*()
関数を使って、VBO → VAO → IBO の順に OpenGL のバッファオブジェクトを作成し、各バッファオブジェクトを互いに関連付けます。
assert(smoothingLevel >= 0.0f && smoothingLevel <= 1.0f);
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &ibo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
コンストラクタに渡されたC++文字列のファイル名を、Objective-Cの文字列へと変換し、それを使って、モデルファイルの場所を表すURLを取得します。- [NSBundle URLForResource:withExtension:]
メソッドを使ってファイルのURLを取得するのは、Cocoa プログラミングの常套手段です。
NSString *theFilename = [NSString stringWithCString:filename.c_str() encoding:NSUTF8StringEncoding];
NSURL *theURL = [[NSBundle mainBundle] URLForResource:[theFilename stringByDeletingPathExtension]
withExtension:theFilename.pathExtension];
if (!theURL) {
throw GameError("Cannot locate a mesh file with name: %s", filename.c_str());
}
取得したURLを使って、MDLAsset
というクラスのオブジェクトを作成します。読み込みに成功していれば、1つ以上の MDLMesh
クラスのオブジェクトが子供として格納されているはずですので、childObjectsOfClass:
メソッドを使って、MDLMesh
クラスを指定して子供のオブジェクトを取り出します。
MDLAsset *asset = [[MDLAsset alloc] initWithURL:theURL];
NSArray *assets = [asset childObjectsOfClass:[MDLMesh class]];
if (assets.count > 0) {
MDLMesh *mesh = assets[0];
...
}
Model I/O はモデルファイルに書かれている情報を単純に読み込むだけですので、法線ベクトルの情報がない Stanford Bunny を読み込んだ場合、頂点情報のデータだけが読み込まれています。そこで、読み込まれている情報の数をカウントして、頂点情報しか読み込まれていない場合に、法線ベクトルを追加で作成するための addNormalsWithAttributeNamed:creaseThreshold:
メソッドを呼び出します。このメソッドの第1引数には、Model I/O 標準で法線ベクトルを表すための名前の定数である MDLVertexAttributeNormal
を指定します。第2引数には、smoothingLevel
変数で表されるスムージングの強さを渡します。スムージングの強さを表す creaseThreashold
引数の値は、0.0 がスムージングあり、1.0 がスムージングなしとなっていますので、1.0 から smoothingLevel
変数の値を引いて、逆にしたものを渡しています。
if (MDLMesh_CountVertexBuffers(mesh) == 1) {
[mesh addNormalsWithAttributeNamed:MDLVertexAttributeNormal
creaseThreshold:(1.0f - smoothingLevel)];
}
メッシュに読み込まれている情報の数をカウントするための MDLMesh_CountVertexBuffers()
関数は、このファイルの上部に定義しています。vertexBuffers
プロパティを使って、読み込まれたすべてのバッファにアクセスします。単純に count
を参照するだけでも良いのですが、場合によってはバッファがないことを表す NSNull
クラスのオブジェクトが含まれている場合がありますので、念のために NSNull
クラスのオブジェクトが現れるまでの有効なオブジェクトの個数をカウントしています。
static int MDLMesh_CountVertexBuffers(MDLMesh *mesh)
{
NSArray<id<MDLMeshBuffer>> *buffers = mesh.vertexBuffers;
int ret = 0;
for (id<MDLMeshBuffer> buffer in buffers) {
if ([buffer isKindOfClass:[NSNull class]]) {
break;
}
ret++;
}
return ret;
}
こうして読み込まれたメッシュの頂点座標と法線座標の情報を、vertexAttributeDataForAttributeNamed:
メソッドを使って取り出します。それぞれ、Model I/O の頂点座標を表す標準の名前である MDLVertexAttributePosition
定数と、法線ベクトルを表す標準の名前である MDLVertexAttributeNormal
定数を指定します。
取り出された MDLVertexAttributeData
型の変数には、各データの先頭のアドレスを表す dataStart
と、データが何ビットずつ離れて格納されているかを表す stride
という要素が用意されています。VBO に渡すデータは、通常、「頂点座標1, 法線ベクトル1, カラー1, 頂点座標2, 法線ベクトル2, カラー2, ...」という順番で格納するのですが、Model I/O で読み込まれたデータは、「頂点座標1, 頂点座標2, ...」と「法線ベクトル1, 法線ベクトル2, ...」という並び順でデータが別個に格納されていますので、VBO で読み込む前に、VBO に適した並び順にデータを格納し直しておきます。これまで通り、VertexData
型の値を複数格納する vertices
変数を用意して、頂点座標と法線ベクトルの先頭のデータから順に値を取り出して、データを追加しています。
std::vector<VertexData> vertices;
MDLVertexAttributeData *attrPos = [mesh vertexAttributeDataForAttributeNamed:MDLVertexAttributePosition];
MDLVertexAttributeData *attrNormal = [mesh vertexAttributeDataForAttributeNamed:MDLVertexAttributeNormal];
float *p0 = (float *)attrPos.dataStart;
float *p1 = (float *)attrNormal.dataStart;
for (NSUInteger i = 0; i < mesh.vertexCount; i++) {
VertexData v;
v.pos.x = *(p0);
v.pos.y = *(p0 + 1);
v.pos.z = *(p0 + 2);
v.normal.x = *(p1);
v.normal.y = *(p1 + 1);
v.normal.z = *(p1 + 2);
v.color = color;
vertices.push_back(v);
p0 += attrPos.stride / sizeof(float);
p1 += attrNormal.stride / sizeof(float);
}
実は
MDLMesh
の情報を元に OpenGL 用のデータに変換するためのGLKMesh
というクラスが GLKit フレームワークに用意されているのですが、サンプルなどが見つからず、ドキュメントもほとんど用意されていません。GLKit 用のアロケータを用意してMDLMesh
オブジェクトからGLKMesh
オブジェクトを作ることを試したりしても、筆者の環境ではエラーが出てうまく読み込めませんでした。そのため、この記事では、MDLMesh から頂点データを直接取り出すようにしています。
頂点データの情報は MDLMesh
クラスのオブジェクト自身が格納しているのですが、インデックス情報は、submeshes
変数(MDLSubmesh
クラスの配列)で表される子供の中に格納されています。読み込むデータの個数などに応じて、インデックスは、8ビット・16ビット・32ビットいずれかの符号なし整数型で表されています。indexType
変数でビット数を確認したら、それに応じて、インデックスの型を格納して、インデックスの個数を計算して、最後にインデックスのデータを glBufferData()
関数で IBO にアップロードします。頂点座標や法線ベクトルとは異なり、IBO は単体で扱われるデータなので、MDLSubmesh
の情報を直接 glBufferData()
関数に渡すことができます。
geometryType
でメッシュを構成するポリゴンの形状を確認できますが、ゲームでは多くの場合、複数の三角ポリゴンで表されたメッシュを使用するため、ここでは MDLGeometryTypeTriangles
定数で表される三角ポリゴンのメッシュだけを読み込むことにしています。
for (MDLSubmesh *submesh in mesh.submeshes) {
if (submesh.geometryType != MDLGeometryTypeTriangles) {
throw GameError("Mesh data should be composed of triangles");
}
id<MDLMeshBuffer> indexBuffer = submesh.indexBuffer;
GLsizeiptr indexDataSize;
if (submesh.indexType == MDLIndexBitDepthUInt8) {
indexType = GL_UNSIGNED_BYTE;
indexCount = (GLsizei)indexBuffer.length;
indexDataSize = sizeof(GLubyte);
} else if (submesh.indexType == MDLIndexBitDepthUInt16) {
indexType = GL_UNSIGNED_SHORT;
indexCount = (GLsizei)(indexBuffer.length / 2);
indexDataSize = sizeof(GLushort);
} else if (submesh.indexType == MDLIndexBitDepthUInt32) {
indexType = GL_UNSIGNED_INT;
indexCount = (GLsizei)(indexBuffer.length / 4);
indexDataSize = sizeof(GLuint);
} else {
throw GameError("Mesh data type is invalid");
}
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indexDataSize * indexCount, indexBuffer.map.bytes, GL_STATIC_DRAW);
}
MeshクラスのDraw()
関数では、これまで通り VAO を指定して、利用するデータの要素番号を指定して、glDrawElements()
関数を呼び出します。第2引数にはインデックスの個数を、第3引数には、読み込み時に確認しておいたインデックスの型(8ビット/16ビット/32ビットの符号なし整数型)を指定します。
void Mesh::Draw() const
{
glBindVertexArray(vao);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);
glDrawElements(GL_TRIANGLES, indexCount, indexType, (void *)0);
}
4. 描画コードの書き換え
それでは、Mesh
クラスを利用して3Dモデルを読み込んで表示するように、描画コードを書き換えてみましょう。
まず、Mesh クラスのヘッダファイル Mesh.hpp を読み込むように、Game.hpp にインクルード文を追加します。
#include "Mesh.hpp"
次に、Game クラスの宣言部分を、次のように書き換えます。VBO, VAO, IBO の宣言が Mesh クラスに移動したので、すべて Mesh クラスのオブジェクトに置き換えられています。
class Game
{
private:
ShaderProgram *program;
GLKVector3 cameraPos;
Mesh *mesh;
public:
Game();
~Game();
public:
void Render();
};
Gameクラスのコンストラクタの実装を、次のように書き換えます。3Dモデルの読み込みのためのコードがすべて Mesh クラスに移動したので、とてもスッキリしました。
Game::Game()
{
glEnable(GL_DEPTH_TEST);
program = new ShaderProgram("myshader.vsh", "myshader.fsh");
mesh = new Mesh("bunny.obj");
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
cameraPos = GLKVector3Make(0.0f, 0.0f, 5.0f);
}
デストラクタでも、これまであった VBO, VAO, IBO の後始末のコードを、Mesh クラスのオブジェクトの解放だけに書き換えます。
Game::~Game()
{
delete program;
delete mesh;
}
VAO を指定しての描画部分も、Mesh クラスの Draw()
関数の呼び出しで置き換えます。
void Game::Render()
{
...
program->SetUniform("specular_color", GLKVector4Make(1.0f, 1.0f, 1.0f, 1.0f));
program->SetUniform("specular_shininess", 10.0f);
mesh->Draw();
}
ここまでできたら、実行してみましょう。スムージングが有効になったことで、ポリゴンの境目がくっきりと分かるこれまでの描画とは異なり、とても滑らかにモデルがレンダリングされるようになったことが分かります。
ここまでのプロジェクト:MyGLGame_step4-6.zip
あくまで法線ベクトルの計算方法が変わって滑らかに表示されるようになっただけで、ポリゴンの数が増えたわけではありません。つまり、計算量やパフォーマンスはこれまでとまったく同じです。
5. まとめ
今回は、Model I/O フレームワークを使って3Dモデルを読み込み、スムージングを使った法線ベクトルの生成ができるようにしました。3Dモデルが滑らかに表示されるようになったことで、今後、より細かいライティングの調整などがテストできるようになりました。
Model I/O は、これまで使ってきた Wavefront OBJ フォーマット (.obj)に加えて、Polygon フォーマット (.ply)、Alembic フォーマット (.abc)、Standard Tessellation Language フォーマット (.stl) の読み込みがサポートされています(2018年1月24日現在。詳しくはこちらを参照)。今回の改良で、様々なフォーマットの3Dモデルが読み込めるようになりましたので、3Dモデルのファイルをいろいろ探して、描画してみてください。