Boost.Spirit.QiによるXモデルパーザ実装の紹介①

※先に断っておきますがバイナリフォーマット、スキンメッシュ及びアニメーションのパーズは未実装です。

 DirectX11からはD3DX系統の補助関数的なAPIはすべて排除されたため、シェーダーのコンパイル、テクスチャの読み込み、モデルの読み込みはユーザーが実装する必要がある。(Microsoftは別途ライブラリを用意する形にしてくれているが、結局外部ライブラリとリンクするなら、自分の好きなライブラリなりオレオレライブラリを作ったりする等、選択肢はいろいろある)

 3Dレンダリングエンジン開発において、プログラムの動作を確認するためには、やはりテストデータが必要である。そのため、レンダリングエンジンの開発の前に、3Dモデルのローダの実装は不可欠といえる。

 今回は、DirectX推奨の3Dモデルフォーマット、テキスト形式.xファイルのパーザ―の実装の紹介をする。

 C++でテキストファイルのパーズとなると、多くの人間が頭を悩ませることだろう。大抵の場合、文字列を線形探索して識別子を取得し、if文で分岐のような実装になるのだろうか。C++は文字列を言語でサポートしていないので、std::stringの恩恵を受けても中々に厳しい実装となることは想像に難くない。

 あるいは、C++11でサポートされた正規表現を使うという手もある。ただし、正規表現でファイルフォーマットの解析というのは、少しばかり無理があるような気もする。正規表現には、ネストされた構文の表現が困難であるといった様々な特性がある。正規表現がダメという話ではない。相性の問題だ。

 ところで、C++ Boost Libraryには、おあつらえ向きの構文解析ライブラリがある。Boost.Spirit.Qiだ。
 Boost.Spirit.Qiは、構文解析C++の文法で完結する闇のライブラリである。冗談抜きで闇だ。
 
 文法はBNF文法に近く、演算子オーバーロードによって、C++の構文の範囲内で文法を定義することが出来る。

 簡単な例を挙げてみよう。

#include <boost/spirit/include/qi.hpp>
#include <iostream>

using namespace boost;
namespace qi = boost::spirit::qi;

int main() {
    std::string const test = "1235_124";

    boost::fusion::vector<int, int> result; 
    
    auto itr = test.begin();
    auto rule = qi::int_ >> qi::lit('_') >> qi::int_;
    if (qi::parse(itr, test.end(), rule, result)) {
        std::cout << fusion::at_c<0>(result) << ',' << fusion::at_c<1>(result) << std::endl;
    } else {
        std::cout << "failed." << std::endl;
    }
}

 

Output
1235,124

 Boost.Spirit.Qi(以降Qiと呼称する)では、一つの文法に相当するものをRuleと呼ぶ。
 上記のコードの場合、任意の整数+'_'という文字+任意の整数という構文をRuleと定義している。

 ちなみにBoost.Fusionシーケンスを内部で作る版の(疑似)可変長変数版のパーズもある。こちらの方が使いやすいだろう。

#include <boost/spirit/include/qi.hpp>
#include <iostream>

namespace qi = boost::spirit::qi;

int main() {
    std::string const test = "1235_124";

    int r1, r2;
    auto itr = test.begin();
    auto rule = qi::int_ >> qi::lit('_') >> qi::int_;
    if (qi::parse(itr, test.end(), rule, r1, r2)) {
        std::cout << r1 << ',' << r2 << std::endl;
    } else {
        std::cout << "failed." << std::endl;
    }
}

 RuleにはAttributeという概念があって、構文解析後の値をどのような型として振舞うのかを示す。例えば、qi::int_のAttributeはintである。qi::litはUnused。つまりバインドされない。すると、全体のAttributeは「int, int」となり、そしてこのAttributeがqi::parseに渡されるバインド先の変数(これらもAttributesと呼ばれるようだ)と一致していなくてはならない。(実際には、Boost.Fusionシーケンスはスライシングを許容しているので、渡す変数が少ない分にはちゃんと機能する)

 これはQiの最も基本的な機能に過ぎない。Qiの機能をここで解説しきるには、残念ながら余白と私の知能が足りない。


 さて、では早速Xモデルパーザの実装の紹介をしていく。

 Xモデルのフォーマットは簡潔に述べると以下のようになる。

・ヘッダー
・テンプレート定義
・データボディ

 最終的なアウトプットとなるデータ構造を以下に示す。

class DXModel
{
public:
    enum class FileFormat {
        NONE,
        TEXT,
        BINARY,
        TZIP,
        BZIP
    };

    struct XHeader {
        std::string magic;
        std::string version;
        FileFormat format;
        int floatSize;
    };
    XHeader header;

    struct XProperty {
        std::string type;
        std::string name;
        int size;
    };

    struct XTemplate {
        std::string TemplateName;
        std::string UID;
        std::vector<XProperty> properties;
    };

    std::vector<XTemplate> templates;

    struct MeshTextureCoords {
        DWORD nTextureCoords;
        std::vector<Vector2D> textureCoords;
    };

    struct MeshFace {
        DWORD nFaceVertexIndices;
        std::vector<DWORD> faceVertexIndices;
    };

    struct MeshNormals {
        DWORD nNormals;
        std::vector<Vector3D> normals;
        DWORD nFaceNormals;
        std::vector<MeshFace> faceNormals;
    };

    struct MeshVertexColors {
        DWORD nVertexColors;
        std::vector<Vector4D> vertexColors;
    };

    struct Material {
        Vector4D faceColor;
        float power;
        Vector3D specularColor;
        Vector3D emissiveColor;
        std::string textureFileName;
    };

    struct MeshMaterialList {
        DWORD nMaterials;
        DWORD nFaceIndexes;
        std::vector<DWORD> faceIndexes;
        std::vector<DXModel::Material> materials;
    };

    struct Mesh {
        DWORD nVertices;
        std::vector<Vector3D> vertices;
        DWORD nFaces;
        std::vector<std::vector<int>> faces;

        // optional data elements
        MeshTextureCoords meshTextureCoords;
        MeshNormals meshNormals;
        MeshVertexColors meshVertexColors;
        MeshMaterialList meshMaterialList;
    } mesh;
};


 まずはヘッダーのパーザを作ってみる。QiにはGrammerというRuleの集まりを表現する型がある。HeaderというGrammerを作ってみよう。
 Xモデルヘッダーの形式の例は以下。

xof 0302txt 0064

 xofはマジックナンバーで固定。0302はバージョン番号。txtは形式。ほかにもバイナリやtzipなどがある。0064は浮動点小数精度。グラマーにすると...

struct FileFormatSymbol : public qi::symbols<char, DXModel::FileFormat> {
    FileFormatSymbol()
    {
        add("txt", DXModel::FileFormat::TEXT)
            ("bin", DXModel::FileFormat::BINARY)
            ("tzip", DXModel::FileFormat::TZIP)
            ("bzip", DXModel::FileFormat::BZIP);
    }
};

template<typename Iterator>
struct XHeaderGrammar : public qi::grammar<Iterator, DXModel::XHeader()> {
    XHeaderGrammar() : XHeaderGrammar::base_type(expr) {
        expr = +(qi::char_ - qi::lit(' '))
            >> qi::lit(' ') >> +(qi::char_ - (format | qi::lit(' ')))
            >> format >> qi::lit(' ')
            >> qi::int_
            >> qi::omit[*qi::space];

    }
    FileFormatSymbol format;
    qi::rule<Iterator, DXModel::XHeader()> expr;
};

 qi::symbolsで、文字列とEnum識別子のテーブルを作成している。これによって、特定の文字列にマッチするFileFormatのAtributeを持つRuleを定義することができる。
 +(qi::char_ - qi::lit(' '))は、空白を含まない文字の1個以上の繰り返しを意味する。つまり先頭のxofにマッチする。+演算子によってAttributeはstd::vectorとして振舞われるため、ここでのAttributeはstd::vectorとなる。ちなみにstd::stringとしても振舞ってくれる。Qi内部ではこういった似た型をよしなに変換してくれる機能も提供している。
 +(qi::char_ - (format | qi::lit(' ')))は空白及びファイルフォーマットを含まない文字の一個以上の繰り返し。0302まで。
 qi::omitは、[]の中のルールをパーズするが、それらをまとめて無視するディレクティブだ。つまり任意のRuleのAttributeをUnusedとして扱う。

 全体のAtributeをまとめると、「std::string、std::string、FileFormat、int」となっている。これは、XHeaderの定義と一致している。もちろんXHeaderは独立したユーザー定義型であるので、このままではこのRuleにバインドできない。そのためには、Boost.Fusionのアダプトという機能を利用するのだが、これは次回解説する。

 次に、テンプレート定義のGrammerを定義しよう。
 テンプレート定義の例は以下。

template MeshMaterialList {
 <F6F23F42-7686-11cf-8F52-0040333594A3>
 DWORD nMaterials;
 DWORD nFaceIndexes;
 array DWORD faceIndexes[nFaceIndexes];
 [Material]
}

 MeshMaterialListというテンプレートを定義するよ、という宣言だ。ボディの一行目はUID。それ以降は、型と変数名の列挙だ。しかし、ここで面倒くさいことに、特定の配列がそれ以前のプロパティの値に依存することがある。今回の例でいうとfaceIndexesがそれにあたる。しかし、ここでデータの持ち方を動的配列と考えることで、豪快に配列のサイズを無視することにする。
 ちなみに[Material]はテンプレートの限定使用という宣言らしい。個人的にはMaterialというテンプレートを入れ語構造にもつよ、という宣言だと認識しているのだが、公式のMSDNでは

テンプレートの限定使用
テンプレートは、開くか閉じることができ、限定使用も可能である。これにより、テンプレートに定義されたデータ オブジェクトの直接階層で表示されるデータ型を決定できる。開かれたテンプレートには制約はなく、閉じられたテンプレートはすべてのデータ型を拒否する。限定使用テンプレートでは、指定したデータ型を使える。

 なにを言っているのか全然わからない。日本語でしゃべってくれ。お前は本当にファイルフォーマットの話をしているのか? ちなみにMSDNは大概クソみたいな翻訳(すべて原文は英語)なので、意味が通っていないどころが誤訳によりまったく意味が変わっていることも多々ある。DirectXの敷居が高いのもそのせいだといっても過言ではない。原文すら間違いがあることもある(実際、参照してるXファイルフォーマットのリファレンスは間違いだらけである。特に型のミスが多い)。込み入ったAPIの仕様を確かめようとすると、基本的に絶望しか待っていない。今回もその系統だろうか。つまり何が言いたいのかというと、編集リクエストボタンを用意していないくせにまともな解説も提供できないMSDNはクソということである。

 というわけで意味がわかめすぎるので、このテンプレート限定使用とやらも無視する。qi::omitで消し飛ばしてやろう。

template <typename Iterator>
struct XTemplateGrammar : public qi::grammar<Iterator, DXModel::XTemplate(), qi::space_type> {
    XTemplateGrammar() : XTemplateGrammar::base_type(expr) {
        expr = qi::no_skip[qi::lit("template") >> +qi::lit(' ') >> +(qi::char_ - (qi::lit('{') | qi::space)) >> qi::omit[*qi::space] >> qi::lit('{')]
            // PARSE UID
            >> qi::lit('<') >> +(qi::char_ - qi::lit('>')) >> qi::lit('>')
            // PARSE PROPERTIES
            >> +prop >> qi::omit[*(qi::char_ - qi::lit('}'))] >> qi::lit('}');

        // PROPERTY RULE
        prop = &qi::alpha >> -qi::lit("array") >> qi::lexeme[+(qi::char_ - qi::lit(' '))] >> +(
            qi::char_ - (qi::lit(';') | qi::lit('['))
            ) >> ((qi::lit('[') >> qi::int_ >> qi::lit(']')) | qi::attr(1)) >> qi::omit[*(qi::char_ - qi::lit(';'))] >> qi::lit(';');
    }

    qi::rule<Iterator, DXModel::XTemplate(), qi::space_type> expr;
    qi::rule<Iterator, DXModel::XProperty(), qi::space_type> prop;
};

 ここで新たな概念が登場している。Grammerのテンプレート引数に、qi::space_typeが渡されている。これは、パースにおいてスキップ設定が有効な時に空白及び改行をすべて無視するよ、という宣言だ。"AA BB"を+qi::char_でパースするとAABBという結果が返ってくることになる。

 まずはテンプレートの識別子をパースする。qi::no_skipとは、[]内のRuleにおいてスキップ設定を無効化するというディレクティブである。いや無効化するのかよ!とツッコミをいれたいところだが、スペースを識別子のセパレータに使うケースは多々あるので仕方ない。今回の場合も「template 識別子」のようにスペースで区切られている。
 また、今回は任意の回数のプロパティ宣言の繰り返しの表現を簡潔にするために、プロパティのRuleを別途定義して、それに+演算子を適用している。

 qi::alphaはアルファベットにマッチするPrimitive Rule。これに&演算子をつけると、「アルファベットにマッチするがイテレータは進めない」というRuleになる。つまり存在確認みたいなものだ。これは }で終わるかプロパティの宣言が続いてるかの判定に使っている。
 ai::lexemeはまあ大体no_skipと一緒。 

 (qi::lit('[') >> qi::int_ >> qi::lit(']')) | qi::attr(1)は、[(int)]という表現にマッチする、もしくはマッチしない場合は1として扱うRuleだ。
 [3] -> 3、[AAA] -> 1、その他 -> 1のようにパースする。
 
 

 さて、これでヘッダーとテンプレート識別子のパーズは終わりだ。ここまではファイルフォーマットの宣言部分に相当する。
 つまり、これから大量のデータボディに対するパーズが残っているわけだが....解説は次回に回そう。

 この記事を読んでいるあなたに、Boost.Spirit.Qiの魅力が伝わればと思う。