8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

macOSでOpenGLプログラミング (4-6. Model I/OでOBJファイルを本格的に読み込む)

Last updated at Posted at 2018-01-24

macOSでOpenGLプログラミングの目次に戻る

はじめに

これまでは頂点座標と面データだけで構成されている Stanford Bunny の単純なOBJファイルだけを使ってきたので、頂点座標と面データの読み込みだけをサポートしていました。しかしOBJファイルには、3Dモデルが複数のグループに分かれて格納されていることもありますし、法線ベクトルや頂点座標が格納されていることもあります。そういった3Dモデルを読み込むためには、しっかりとした読み込みのコードを書く必要があります。

また、これまでは3Dモデルを構成するポリゴンの法線ベクトルを、外積を使って単純に計算していましたが、スムージングという処理を加えることによって、いかにもポリゴンということが分かるカクカクした見た目を改良できます。スムージングとは、隣り合ったポリゴンの角度を考えながら、ポリゴン各頂点の法線ベクトルを傾かせることによって、角が滑らかに見えるようにする処理です。次の図は、左がスムージングなし、右がスムージングありでレンダリングした例です。

  bunny2.png

この記事では、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 ファイルが出来上がります。

 

 mesh5.png

Model I/O の API は Objective-C からアクセスしますので、Mesh.cpp ファイルの拡張子を Mesh.mm に変更して、Objective-C++ でコンパイルされるようにしておきます。

 

3. Meshクラスを実装する

Mesh.hpp にMeshクラスの宣言を書きます。

Mesh.hpp
//
//  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
//
//  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 にインクルード文を追加します。

Game.hpp(一部)
#include "Mesh.hpp"

次に、Game クラスの宣言部分を、次のように書き換えます。VBO, VAO, IBO の宣言が Mesh クラスに移動したので、すべて Mesh クラスのオブジェクトに置き換えられています。

Game.hpp(一部)
class Game
{
private:
    ShaderProgram   *program;
    GLKVector3      cameraPos;
    Mesh            *mesh;

public:
    Game();
    ~Game();

public:
    void    Render();
};

Gameクラスのコンストラクタの実装を、次のように書き換えます。3Dモデルの読み込みのためのコードがすべて Mesh クラスに移動したので、とてもスッキリしました。

Game.cpp(コンストラクタ)
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.cpp(デストラクタ)
Game::~Game()
{
    delete program;
    delete mesh;
}

VAO を指定しての描画部分も、Mesh クラスの Draw() 関数の呼び出しで置き換えます。

Game.cpp(一部)
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モデルのファイルをいろいろ探して、描画してみてください。


macOSでOpenGLプログラミングの目次に戻る

8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?