catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

Rustでgltfをパースしてみた

お久しぶりですかたりぃなです。 今回はRustの勉強をするために適当なパーサを書いてみたいと思います。 いわゆる木構造的なやつを自力でパースすることでRustともっと仲良くなろうという試みです。

お題としてgltfをパースしてみようと思います。

ざっと書いたコードはこちら。 https://gist.github.com/javoren/dece41a559a1f9d5346d303b1d05d521

gltf is 何?

3DCGのデータ格納フォーマットの一つです。

https://www.khronos.org/api/index_2017/Gltf

理解した範囲ではgltfはこんな感じみたいです。

  • 先頭12バイトがファイルヘッダ
  • 後続に2つのチャンクがある
    • chunk0はテキスト形式。JSONが格納されている。
      • 一般的な3DCGの概念レベルが表現される
    • chunk1はバイナリ形式。chunk0のJSONから参照され、ジオメトリなどの実際のデータが表現される。
      • 頂点や法線、マテリアルなどの実データを表現している

このフォーマットのJSONの理解で手間取ったのが次の3つでした。 次のように理解しました。

  • Buffer
    • chunk1のバイナリデータを複数のBufferに分割する
  • BufferView
    • Bufferを複数のViewとして分割する
  • Accessor
    • 上記BufferViewの指し示すバイナリの解釈を与える
      • たとえば頂点や法線データであれば「32bit浮動小数点形式, VEC3型, 頂点数はN個」など

BufferとBufferViewは多対多関係っぽいですね。ぱっと思いつく使い道としてはパーティクル出すときのポリゴン形状は同じだから使いまわしたいとかでしょうかね。 プリミティブ情報はすべての粒子で共有できるはずなので。

というわけで動作確認用のデータとしてBlenderで適当なキューブのみのシーンをエクスポートしてみました。 次のとおりです。

gltfのすべての要素を実装するのは大変なので、まずはこれをうまくパースしてレンダリングするための情報を抽出するところまで実装してみます。

{
    "asset":{
        "g|enerator":"Khronos glTF Blender I/O v4.3.47",
        "version":"2.0"
    },
    "scene":0,
    "scenes":[{"name":"Scene","nodes":[0]}],
    "nodes":[{"mesh":0,"name":"Cube"}],
    "materials":[
        {
            "doubleSided":true,
            "name":"Material",
            "pbrMetallicRoughness":{
                "baseColorFactor":[0.800000011920929,0.800000011920929,0.800000011920929,1],
                "metallicFactor":0,
                "roughnessFactor":0.5
            }
        }
    ],
    "meshes":[
        {
            "name":"Cube",
            "primitives":[
                {
                    "attributes":
                    {
                        "POSITION":0,
                        "NORMAL":1,
                        "TEXCOORD_0":2
                    },
                    "indices":3,
                    "material":0
                }
            ]
        }
    ],
    "accessors":[
        {
            "bufferView":0,
            "componentType":5126,
            "count":24,
            "max":[1,1,1],
            "min":[-1,-1,-1],
            "type":"VEC3"
        },
        {
            "bufferView":1,
            "componentType":5126,
            "count":24,
            "type":"VEC3"
        },
        {
            "bufferView":2,
            "componentType":5126,
            "count":24,
            "type":"VEC2"
        },
        {
            "bufferView":3,
            "componentType":5123,
            "count":36,
            "type":"SCALAR"
        }
    ],
    "bufferViews":[
        {"buffer":0,"byteLength":288,"byteOffset":0,"target":34962},
        {"buffer":0,"byteLength":288,"byteOffset":288,"target":34962},
        {"buffer":0,"byteLength":192,"byteOffset":576,"target":34962},
        {"buffer":0,"byteLength":72,"byteOffset":768,"target":34963}
    ],
    "buffers":[
            {"byteLength":840}
    ]
}

ファイルを読み込む

Rustでのファイル処理は色々あるみたいですが、今回のテスト用ファイルはサイズもたいしたことないので一旦全部読んでから処理してみます。 たとえばファイルヘッダは bytes クレートを使って次のように解釈できます。

    let mut file = File::open("./test.glb")?;
    let mut buf = Vec::new();
    let _ = file.read_to_end(&mut buf)?;

    let mut p = &buff[..];
    let m1 = p.get_u8();
    let m2 = p.get_u8();
    let m3 = p.get_u8();
    let m4 = p.get_u8();
    let magic:[u8;4] = [m1,m2,m3,m4];
    let version:u32 = p.get_u32_le();
    let length = p.get_u32_le();

チャンク分割も同様に処理してみます。

// result = チャンクヘッダstruct
fn parse_chunk(buff:&[u8]) -> Result<(ChunkHeader, Box<dyn std::error::Error>>{
    let mut p = buff;
    let length = p.get_u32_le() as usize;
    let t1 = p.get_u8();
    let t2 = p.get_u8();
    let t3 = p.get_u8();
    let t4 = p.get_u8();
    let magic:[u8;4] = [t1,t2,t3,t4];
    let body = &buff[4*2..4*2+length];
    Ok(
        ChunkHeader{
            length: length,
            chunk_type: magic,
            body: Vec::from(body),}
    )
}

bytesクレートについて

今回使用したクレートはこちら。

https://docs.rs/bytes/latest/bytes/index.html

バイナリファイルの扱いをラクにしてくれました。

たとえば

    let mut p = &buff[..];
    let m1 = p.get_u8();
    let m2 = p.get_u8();
    let m3 = p.get_u8();
    let m4 = p.get_u8();

みたいな記述ができるのは嬉しいです。

またバイトオーダーや幅についても

    let version:u32 = p.get_u32_le();
    let length = p.get_u32_le();

のように記述できるのは嬉しいポイントです。 Rustでもバイナリを簡単に取り扱えるのはうれしい限りです。

JSONパース

ここまででgltfの大枠が解析できました。

次はchunk0のJSONをパースしてみます。 自力でJSONパーサを全部記述するのは大変なので、serde_jsonを使ってみます。

from_strの戻り値をserde_json::Valueとすることで、ツリー構造が格納されます。

        let json_str = str::from_utf8(&chunk0_header.body)?;
        let json_obj:serde_json::Value = serde_json::from_str(&json_str)?;
        println!("json={:?}", json_obj);
        println!("");

        // jsonの要素を取得するテスト2パターン試す。
        // パターン1, 既知のキーを指定して取り出す
        let asset = &json_obj["asset"];
        println!("asset = {:?}", asset);

これをパースするには各要素をトラバースするコードを書けばいいだけでした。

fn parse_json(value: &serde_json::Value) -> (){
    match value {
        Value::Null => (),
        Value::Bool(_b) => (),
        Value::Number(_num,) => (),
        Value::String(_str) => (),
        Value::Array(_v) => parse_json_array(_v),
        Value::Object(_object) => parse_json_object(_object),
    }
}

fn parse_json_array(v: &Vec<serde_json::Value>) -> () {
    for element in v{
        parse_json(element);
    }
}

fn parse_json_object(obj : &serde_json::Map<String, Value>) -> (){
    println!("object");
    for (k,_v) in obj{
        println!("key={:?}", k);
        parse_json(_v);
    }
}

とりあえずこれでobjectとそのkeyがわかります。足りない箇所を肉付けしていけば何でもできそうな気がしてきますね。

jsonをパースしてstructに格納する

上記はシンプルな機能ゆえに手軽に利用できますが、エラーハンドリングなどを考え始めると色々と面倒なことが増えてきます。 実際にはstructを定義してserde_jsonで自動的にやってもらうのがよさそうです。

詳細は公式ドキュメント https://docs.rs/serde_json/latest/serde_json/

今回の実験用JSONに含まれている要素だけざっと記述するとこうなりました。 全体は長すぎるのでルート要素とasset要素だけ示します。

#[allow(dead_code,non_snake_case,non_camel_case_types)]
#[derive(Deserialize, Debug)]
pub struct gltfJson{
    asset:asset,
    scene:u32,
    scenes: Vec<Scene>,
    nodes:Vec<Node>,
    materials: Vec<Material>,
    meshes: Vec<Mesh>,
    accessors: Vec<Accessor>,
    bufferViews: Vec<BufferView>,
    buffers: Vec<Buffer>,
}

#[allow(dead_code,non_camel_case_types)]
#[derive(Deserialize, Debug)]
struct asset{
    version: String,
    generator:Option<String>,
    copyright:Option<String>,
}
~~~~

serde_jsonのfrom_strでパースした結果を受け取る型を明示する形で使えます。

    let json_str = str::from_utf8(&body)?;
    let gltf:gltfJson = serde_json::from_str(&json_str)?;
    println!("gltf = {:?}", gltf);

gltf規格に記述されている構造やフィールド名をそのまま記述していくだけなのでお手軽です。 今回のポイントは次のとおりです。

  • rustではコーディングルールを満たしていないとコンパイラが警告を出すので、意図したものだということをnon_camel_case_typesなどで指定しています。
  • gltf規格で必須ではないフィールド(この例ではgeneratorとcopyright)はOptionで表現する
  • JSONの子要素も同様にして記述していける。
  • JSONのarrayはVecで対応

structで構成された木構造を巡回する

このデータ構造を巡回してレンダリングすることを考えます。 実際に3DCGライブラリ叩いたりとかし始めると本題からそれてしまうので、レンダリングに必要なデータをコンソールにprintするだけにとどめます。

今回パースしたgltfをレンダリングすることを考えた時、次のようになりました。 ただし、この形はエラーを握りつぶしているので良くないです。if let Ok()自体は便利なのですけどね。。。

pub fn render(gltf:GltfComponent) -> Result<(), Box<dyn std::error::Error> > {
    println!("render start!");
    if let Ok(nodes) = traverse_scene(&gltf.json) {          // レンダリング対象のシーンに含まれているノードの集合を得る
        nodes.iter().for_each(
            |node_num| {
                if let Ok(mesh) = traverse_node(&gltf.json, *node_num as usize){             // ノードすべてを見ていく
                    if let Ok(primitives) = traverse_mesh(&gltf.json, mesh){     // ノード中のメッシュ
                        primitives.iter().for_each(                                          // メッシュ中の全プリミティブ
                            |p| {
                                let _ = traverse_primitive(&gltf.json, &gltf.bin, p);
                            }
                        );
                    } else {
                        // エラー発生。どうする?
                    }
                } else {
                    // エラー発生。どうする?
                }
            }
        );

        println!("render finish!");
    } else{
        // エラー発生。どうする?
    }
    Ok(())
}

ここでコメントに記述しているようなエラーを呼び出し元に伝えるには?というシンタックスシュガーを使えます。

pub fn render(gltf:GltfComponent) -> Result<(), Box<dyn std::error::Error> > {
    println!("render start!");
    let nodes = traverse_scene(&gltf.json)?;                        // レンダリング対象のシーンに含まれているノードの集合を得る
    for node_num in nodes.iter(){
        let mesh = traverse_node(&gltf.json, *node_num as usize)?;      // ノードすべてを見ていく
        let primitives = traverse_mesh(&gltf.json, mesh)?;    // ノード中のメッシュ
        for primitive in primitives.iter(){
            let _ = traverse_primitive(&gltf.json, &gltf.bin, primitive);
        }
    }

    println!("render finish!");

    Ok(())
}

関数名(引数)?; の形で記述している箇所がエラーハンドリングのシンタックスシュガーです。 呼び出し結果がErr()の場合は呼び出し元にエラーを返し、https://gist.github.com/javoren/dece41a559a1f9d5346d303b1d05d521#file-gistfile1-txt-L273Ok()の場合は処理は継続されるというものです。

便利で可読性も向上していいですね。

ちなみにunwrap()を使う例もあったりしますが、あれはエラー時にその場でpanic!()するので意味合いが異なると思っています。

C++のエラーハンドリングとの対比で考えると

  • Rustには例外機構はないので、Result型戻り値を使うことで常系か異常系かを伝える
    • 呼び出し元がエラーを検知してさらに呼び出し元に異常を伝えるには?構文が使える
  • Rustのunwrap()は内部的にはC++の exit(-1)が近い挙動か。
    • 外見えの挙動としてはResult型戻り値のパターンマッチした結果の正常系の戻り値のみを返す。異常系の場合はexit(-1)

といったところでしょうか。 Resultの扱いはScalaでいうeitherも近い気がしますね。

Rustのジェネリクスまだよくわからない

gltfのバッファの解釈部分なのですが https://gist.github.com/javoren/dece41a559a1f9d5346d303b1d05d521#file-gistfile1-txt-L273 のように記述しています。

型が異なるだけで中身が同じ処理が並んでいるので、ジェネリクスで解決できないかなと色々試していたのですが無理でした。

具体的にやりたいこととしては

  • gltfアクセサのcomponent_typeに記述されている型と要素数をもとに、それを格納するためのメモリ領域(Rustの世界でのVec)を生成する
  • バイナリチャンクのbody部分を必要なだけ読み込む(前述の型に合わせて)

なので、簡易的な疑似コードで

template <typename T>make_vec() -> std::vector<T>{/*impl*/}
template <typename T>read_bin(std::vector<T>, std::vector<char> bindata) -> std::vector<T> {/* impl */}

// 実際に使うときは
auto actual_data1 = read_bin( make_vec<unsigned char>() , bindata);
auto actual_data2 = read_bin( make_vec<unsigned short>() , bindata);
auto actual_data3 = read_bin( make_vec<float>() , bindata);

みたいなことをしたかったのですが、うまくいきませんでした。 このあたりはまだよくわかってないので勉強が必要なところですね。

感想と今後の展望

Rustのいろいろな機能をつかって簡単なパーサを実装できました。 しかしながら納得いかない部分が多々あるので、今後も学習が必要ですね。 一般的なオブジェクト指向的な書き方であればC++のようにできますが、ジェネリクスや関数型のような記述を織り交ぜようとすると詰まってしまいます。

思い返せば私自身、新しいプログラミングパラダイムを学ぶとき毎回こういう感じだったと思います。

まだまだ学ぶことは多いですが、今後も色々試してみたいと思います。 それでは今回はこれくらいで。