4
1

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-2. OBJファイルのモデルを読み込む)

Last updated at Posted at 2018-01-07

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

はじめに

この記事では、3Dモデルをファイルから読み込んで表示させてみましょう。ライティングが有効になると、複雑な3Dモデルを読み込んで表示させることができるようになります。

これまでは手書きで頂点データを編集してきましたが、3Dモデルのファイルからデータを読み込めるようになると、表現力が一気に増します。手書きだと、せいぜい10個〜100個くらいの頂点データしか扱えませんが、3Dモデルから読み込むようにすると、何千・何万といった頂点データを扱えるようになります。

こうなるとこちらのもので、Maya や Blender といった DCC (Digital Content Creation)ツールを使って、思い通りのキャラクタを自分のゲームの中に表示できるようになるのです。Wavefront OBJ(拡張子 .obj)は基本的なフォーマットですから、あらゆるDCCツールがOBJファイルの書き出しに対応しています。

それでは、張り切っていきましょう。

1. 3Dモデルファイルを用意する

今回は「Stanford Bunny」と呼ばれる有名なウサギの3Dモデルのデータを使ってみましょう。OpenGLで3Dモデルと言えば、このウサギとティーポットが有名です。

  

「Stanford Bunny obj」でGoogle検索すれば、graphics.stanford.edu 以下のドメインにあるファイルが一番にヒットします。このデータは、2503個の頂点データと、それを使った4968個のポリゴンメッシュからできています。

 stanford.png

OBJファイルはテキスト形式のフォーマットですので、そのままテキストとして保存して、名前を「bunny.obj」としておきましょう。

この「bunny.obj」ファイルを、Xcodeのプロジェクトの「Resources」グループにドラッグ&ドロップで追加します。

 

ファイルの追加ダイアログが表示されますので、必ず「Copy items if needed」と「Add to targets」の2箇所のチェックボックスがチェックされている状態にして、「Finish」ボタンを押してください。

 

以上で3Dモデルのファイルが追加できました。

 

2. Wavefront OBJファイルのフォーマット

今回利用する拡張子が「.obj」のファイルは、「Wavefront OBJファイル」と呼ばれる、3Dモデルの頂点データを格納するためのファイルです。

フォーマットの詳しい解説は「hiramine.com OBJファイルフォーマット」にあります。どんなOBJファイルにも対応できるようにするのは先送りにして、ここでは「Stanford Bunny」を読み込むのに必要な知識だけを確認しておきたいと思います。

bunny.obj ファイルの中身を見てみましょう。次のようになっています。

# OBJ file format with ext .obj
# vertex count = 2503
# face count = 4968
v -3.4101800e-003 1.3031957e-001 2.1754370e-002
v -8.1719160e-002 1.5250145e-001 2.9656090e-002
...
f 1069 1647 1578
f 1058 909 939
...

これを見ると、このファイルは主に3種類の行から構成されていることが分かります。「#」から始まる行と、「v」から始まる行と、「f」から始まる行です。

「#」から始まる行はコメント行ですので、無視します。

「v」から始まる行には、頂点の位置座標がスペース区切りで書かれています。

「f」から始まる行には、三角メッシュポリゴンを構成する3頂点のインデックス番号が書いてあります(ただしインデックス番号は1から始まりますので、配列のインデックスとして使う場合には-1する必要があります)。

3. 文字列処理の関数を追加する

OBJファイルの中身を処理しやすいように、StringSupport.hpp と StringSupport.mm に文字列処理の関数を追加しましょう。

まずはStringSupport.hppに関数の宣言を追加します。今回はこの中の Split() 関数しか使いませんが、いずれあると便利な StartsWith() 関数(プリフィックス文字列のチェック)や EndsWith() 関数(サフィックス文字列のチェック)、そして文字列の前後から空白文字を取り除くための Trim() 関数も追加しておきます。

StringSupport.hpp(追加分)
/*! 与えられた文字列strを、separatorを区切り文字として分割します。 */
std::vector<std::string> Split(const std::string& str, const std::string& separator);

/*! 与えられた文字列strを、separatorを区切り文字として分割します。(空文字挿入のオプションあり) */
std::vector<std::string> Split(const std::string& str, const std::string& separator, bool ignoreEmptyString);

/*! 文字列strが、文字列valueで始まる文字列かどうかをチェックします。 */
bool StartsWith(const std::string& str, const std::string& value);

/*! 文字列strが、文字列valueで始まる文字列かどうかをチェックします。(大文字・小文字の区別オプションあり) */
bool StartsWith(const std::string& str, const std::string& value, bool ignoreCase);

/*! 文字列strが、文字列valueで終わる文字列かどうかをチェックします。 */
bool EndsWith(const std::string& str, const std::string& value);

/*! 文字列strが、文字列valueで終わる文字列かどうかをチェックします。(大文字・小文字の区別オプションあり) */
bool EndsWith(const std::string& str, const std::string& value, bool ignoreCase);

/*! 文字列の先頭および末尾にある空白文字(タブ文字, 改行文字, 空白文字)をすべて削除します。 */
std::string Trim(const std::string& str);

/*! 文字列の先頭および末尾から、指定された文字セットをすべて削除します。 */
std::string Trim(const std::string& str, const std::string& trimChars);

次に、StringSupport.mmに関数の実装を追加します。

StringSupport.mm(追加分)
std::vector<std::string> Split(const std::string& str, const std::string& separator)
{
    return Split(str, separator, false);
}

std::vector<std::string> Split(const std::string& str, const std::string& separator, bool ignoreEmptyString)
{
    std::vector<std::string> ret;

    std::string::size_type pos = 0;
    std::string::size_type length = str.length();
    if (length == 0) {
        if (!ignoreEmptyString) {
            ret.push_back("");
        }
        return ret;
    }

    while (pos < length) {
        std::string::size_type p = str.find_first_of(separator, pos);
        if (p == std::string::npos) {
            std::string::size_type len = length - pos;
            if (!ignoreEmptyString || len > 0) {
                std::string part = str.substr(pos, len);
                ret.push_back(part);
            }
            break;
        }
        std::string::size_type len = p - pos;
        if (!ignoreEmptyString || len > 0) {
            std::string part = str.substr(pos, len);
            ret.push_back(part);
        }
        pos = p + 1;
        if (pos >= length && !ignoreEmptyString) {
            ret.push_back("");
        }
    }
    return ret;
}

bool StartsWith(const std::string& str, const std::string& value)
{
    return StartsWith(str, value, true);
}

bool StartsWith(const std::string& str, const std::string& value, bool ignoreCase)
{
    auto length = value.size();
    if (str.length() < length) {
        return false;
    }

    std::string sub = str.substr(0, length);
    if (ignoreCase) {
        return (strncasecmp(sub.c_str(), value.c_str(), length) == 0);
    } else {
        return (sub == value);
    }
}

bool EndsWith(const std::string& str, const std::string& value)
{
    return EndsWith(str, value, true);
}

bool EndsWith(const std::string& str, const std::string& value, bool ignoreCase)
{
    auto length = value.size();
    if (str.length() < length) {
        return false;
    }

    std::string sub = str.substr(str.length()-length, length);
    if (ignoreCase) {
        return (strncasecmp(sub.c_str(), value.c_str(), length) == 0);
    } else {
        return (sub == value);
    }
}

std::string Trim(const std::string& str)
{
    return Trim(str, "\t\r\n ");
}

std::string Trim(const std::string& str, const std::string& trimChars)
{
    std::string::size_type left = str.find_first_not_of(trimChars);

    if (left == std::string::npos) {
        return str;
    }
    std::string::size_type right = str.find_last_not_of(trimChars);
    return str.substr(left, right - left + 1);
}

4. 頂点データを読み込む

ファイル読み込みのために、Game.hppの先頭に、C++のファイル操作APIであるfstreamのインクルード文を追加しておきましょう。また3節で追加した文字列処理の関数を使用しますので、StringSupport.hppのインクルード文も追加します。

Game.hpp
#ifndef Game_hpp
#define Game_hpp

#include <OpenGL/OpenGL.h>
#include <OpenGL/gl3.h>
#include <GLKit/GLKMath.h>
#include <fstream>
#include <vector>
#include "Time.hpp"
#include "Input.hpp"
#include "Shader.hpp"
#include "Texture.hpp"
#include "StringSupport.hpp"

それでは、これまで vector<VertexData> 型の data 変数に手書きでデータを追加していたところを、3Dファイルからデータを読み込むように書き換えましょう。

Game.cpp(一部)
Game::Game()
{
    glEnable(GL_DEPTH_TEST);

    program = new ShaderProgram("myshader.vsh", "myshader.fsh");

    // 3Dモデルの読み込み
    std::vector<GLKVector3> vertices;
    std::string modelFilepath = GetFilepath("bunny.obj");
    std::ifstream ifs(modelFilepath);
    std::string line;
    int lineCount = 0;
    while (ifs && getline(ifs, line)) {
        lineCount++;
        if (line.length() > 0 && line[0] == '#') {
            continue;
        }
        std::vector<std::string> parts = Split(line, " ");
        if (parts.size() > 0) {
            if (parts[0] == "v") {
                if (parts.size() < 4) {
                    throw GameError("Invalid obj file format (v): [line=%d] %s", lineCount, line.c_str());
                }
                float x = atof(parts[1].c_str());
                float y = atof(parts[2].c_str());
                float z = atof(parts[3].c_str());
                vertices.push_back(GLKVector3Make(x, y, z));
            } else if (parts[0] == "f") {
                if (parts.size() < 4) {
                    throw GameError("Invalid obj file format (f): [line=%d] %s", lineCount, line.c_str());
                }
                int index0 = atoi(parts[1].c_str()) - 1;
                int index1 = atoi(parts[2].c_str()) - 1;
                int index2 = atoi(parts[3].c_str()) - 1;
                if (index0 >= vertices.size() || index1 >= vertices.size() || index2 >= vertices.size()) {
                    throw GameError("Invalid index value (f): [line=%d] %s", lineCount, line.c_str());
                }
                GLKVector3& v0 = vertices[index0];
                GLKVector3& v1 = vertices[index1];
                GLKVector3& v2 = vertices[index2];
                GLKVector3 cross = GLKVector3CrossProduct(GLKVector3Subtract(v1, v0),
                                                          GLKVector3Subtract(v2, v0));
                GLKVector3 normal = GLKVector3Normalize(cross);
                GLKVector4 color = GLKVector4Make(1.0f, 1.0f, 1.0f, 1.0f);
                data.push_back( VertexData{ v0, normal, color } );
                data.push_back( VertexData{ v1, normal, color } );
                data.push_back( VertexData{ v2, normal, color } );
            }
        }
    }
    // 以下、続く
}

その他の箇所は変更していません。インデックス・リストの作成方法や、VBO・VAO・IBO の作成のためのコードはこれまでとまったく同じです。

それでは実行してみましょう。次のように Stanford Bunny が表示されれば成功です。

 
  ここまでのプロジェクト:MyGLGame_step4-2.zip

5. 3Dモデルを読み込むコードの解説

(※ この節はあとでより詳しく説明を書き直しますが、まずは各箇所にざっくりとコメントをつけておきます(1月7日 15:00 現在)。)

5-1. ファイル読み込みはC++標準の方法で

ファイルパスを取得して、ifstreamクラスのオブジェクトを作る。

std::string modelFilepath = GetFilepath("bunny.obj");
std::ifstream ifs(modelFilepath);

1行ずつ読み込むためのifstreamクラスの使い方。読み込みに問題があった時に、問題の箇所を特定しやすくするために、lineCount変数を用意して、何行目を処理しているか分かるようにしておくのが重要(パーサなどを作る際の基本)。

int lineCount = 0;
while (ifs && getline(ifs, line)) {
    lineCount++;
}

コメント行を無視する処理

if (line.length() > 0 && line[0] == '#') {
    continue;
}

空白文字で切り分ける処理。

std::vector<std::string> parts = Split(line, " ");
if (parts.size() > 0) {
    if (parts[0] == "v") {
        ...
    } else if (parts[0] == "f") {
        ...
    }
}

頂点データの処理。指数表現になっている文字列も、一般的な小数点表現の文字列も、atof() 関数で float 値に変換できる。空白文字で区切った要素の個数を確認し、問題があったら例外をスローしてエラーを報告する。X座標, Y座標, Z座標を変換したら、頂点座標だけを格納しておくためのvectorにpush_back()で追加する。

if (parts[0] == "v") {
    if (parts.size() < 4) {
        throw GameError("Invalid obj file format (v): [line=%d] %s", lineCount, line.c_str());
    }
    float x = atof(parts[1].c_str());
    float y = atof(parts[2].c_str());
    float z = atof(parts[3].c_str());
    vertices.push_back(GLKVector3Make(x, y, z));

三角ポリゴンメッシュのデータ読み込み。頂点データと同様に要素の個数を確認して、問題があったら例外をスローする。atoi() 関数で文字列からint型の数値に変換し、OBJファイルではインデックス番号が1始まりなのを、配列に合わせて0始まりになるようにする。こうして取得したインデックスが、頂点データの個数を超えないことを必ずチェックする。今回使用する Stanford Bunny のOBJファイルには法線ベクトルのデータが含まれていないので、3頂点の座標を元に、外積を使って法線ベクトルを計算する。

} else if (parts[0] == "f") {
    if (parts.size() < 4) {
        throw GameError("Invalid obj file format (f): [line=%d] %s", lineCount, line.c_str());
    }
    int index0 = atoi(parts[1].c_str()) - 1;
    int index1 = atoi(parts[2].c_str()) - 1;
    int index2 = atoi(parts[3].c_str()) - 1;
    if (index0 >= vertices.size() || index1 >= vertices.size() || index2 >= vertices.size()) {
        throw GameError("Invalid index value (f): [line=%d] %s", lineCount, line.c_str());
    }
    GLKVector3& v0 = vertices[index0];
    GLKVector3& v1 = vertices[index1];
    GLKVector3& v2 = vertices[index2];
    GLKVector3 cross = GLKVector3CrossProduct(GLKVector3Subtract(v1, v0),
                                              GLKVector3Subtract(v2, v0));
    GLKVector3 normal = GLKVector3Normalize(cross);
    GLKVector4 color = GLKVector4Make(1.0f, 0.75f, 0.0f, 1.0f);
    data.push_back( VertexData{ v0, normal, color } );
    data.push_back( VertexData{ v1, normal, color } );
    data.push_back( VertexData{ v2, normal, color } );
}

6. まとめ

この記事では、3DモデルのファイルフォーマットであるOBJファイルを読み込めるようにし、これまでとは段違いに複雑な本格的な3Dデータを画面に表示できるようにしました。

ただし、今回は比較的単純なOBJファイルである Stanford Bunny を読み込めるようにしただけなので、あらゆるOBJファイルを読み込めるようにするには、もう少し手直しが必要です。今後の記事では、それも修正していきましょう。


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

4
1
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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?