皆様は日々自前のシーングラフを作り、3Dのシーンを階層的に表現していることと思います。
シーングラフ(Scene Graph)はこういうやつです。
奇しくも日本語発音だとシーグラフ (SIGGRAPH) と編集距離1なのでかっこいいですね。
シーングラフを触っていると、さらに欲望が湧いてきてメモリ上のシーングラフを標準的なフォーマットでファイルにエクスポートしたくなってきますね。
シーングラフを表現できる標準的なフォーマットとしてはFBXが有名ですが、Qiitaを読んでいる程度に技術力が高く先進的な考えをお持ちの3DCG技術者の皆様が新規開発で使う必要はないでしょう。
将来性を考えるとPBRをサポートしている次世代のフォーマットを使うべきでしょう。
Khronos Group発でGoogleが推しているglTFか、
Pixar発でAppleやAdobeが推しているusd(usdz)の二択になりますね。
私はglTFのほうを使いたいです、AppleよりはGoogleのほうがless邪悪という認識なので。
またglTFのほうが機能が限定されていてフォーマットが簡単な雰囲気を感じます。
映画クオリティのフォトリアリスティックなシーンを表現するには難しそうなusdに任せましょう。
glTFはWindowsだと標準の3Dビューワで見れますが、Windowsを持ってない可哀そうな人はBlenderを使うかこのWebのビューワでみるのがいいと思います。
Blenderを使えば書き込みもできますがそもそもBlenderに自前シーングラフを読み込ませるのは面倒です。
自前シーングラフは自前でglTFにエクスポートしましょう。信じる者は救われるのです。
内容的にはこの記事の書き込み版です。
glTFのフォーマット
公式のspecificationはこのgithubのREADME.mdらしいです。
チートシートもあります。
チートシートの日本語訳もあります。
残念ながらチートシートすらお世辞にもわかりやすいとは言えないので簡単に説明します。
glTFはjson(テキスト)+バイナリ+テクスチャ画像からなります。
分けてファイルに書くと.gltf(JSON)+.bin(バイナリ)+画像(拡張子はなんでもOK)、連結して一つのバイナリファイルに書くと.glbとなりますが総称してglTFと呼ばれています。
jsonにはシーングラフのノードの階層関係やバイナリの読み方や解釈方法を記述します。
バイナリにはジオメトリの頂点位置やノーマル、面を貼る頂点インデックスなどなどGPUにまとめて転送するようなデータが連結された状態で入っています。こういうデカいデータをテキストで持つとファイル容量がバカデカくなってしまうのでバイナリで持つのは合理的ですね(.objでデカいメッシュを扱うと気が狂いそうになりますよね)。何バイトからどこまでがfloat32 vec3の頂点位置で~といった情報はjsonが持っています。
画像はjsonからマテリアルのテクスチャとして参照されます。
参考までにKhronos様公式のDuck.gltfの中身をみてみましょう。繰り返しますが.gltfはjsonです。
最低限色付きの静的なメッシュを表現するのに必要なノード、ジオメトリ、マテリアルだけを抜き出し、各メンバ間の関係を示した図が以下になります。
- nodes
- ノード情報。Transformは4×4のmatrix、もしくはtranslation(vec3), scale(vec3), rotation (vec4、クオータニオン)にわけても書ける
- meshes
- メッシュ情報。attributesとindicesはaccessorのID。materialはマテリアルID
- materials
- マテリアル情報。indexはtextureのID。
- textures
- sourceはimageのID
- images
- imageのuri(.gltf)やmimeType(.glb)
- accessors
- bufferViewの解釈。componentTypeは型、countは総数、typeは1単位あたりの数。POSITIONに関してはmaxとminが必須であることに注意。
- bufferViews
- buffer内の連続した塊
- buffers
- バイナリデータ
シーングラフをglTFに変換する際の問題
一番の問題はシーングラフは普通はオブジェクト指向で実装すると思いますが、一方で変換先であるglTFのフォーマットはオブジェクト指向ではなく関係データベース(RDBMS)のような形式になっているということです。
仮にシーングラフを単純にシリアライズするとノード単位で連続したデータがツリー構造をフラットにした順序で書き込まれると思います。
一方でglTFの場合はデータはノードの階層関係(JSON)とノードのデータ(バイナリ、画像)は別々の場所にありIDで参照するようになっていますし、バイナリのデータもノード単位で連続していません(バイナリのパッキングには高い自由度があるので連続させることもある程度可能ではありますが)。
これはORマッピングにおけるインピーダンスミスマッチの問題そのものです。
ですので自前でORマッパーもどきを書くことになります。
シーングラフをglTFに変換し書き込むアルゴリズム
特定のプログラミング言語に依存せず、自前シーングラフの実装にも依存しない一般的な話を書きます。
話を簡単にするために、変換対象はトランスフォームと静的なメッシュだけを考えます。
glTFとの整合性を考え天下り的ではありますが、メッシュは複数のPrimitiveからなり各Primitiveが頂点属性と面情報とPBRマテリアルを持っていることにします。
トランスフォームノードの子孫にジオメトリノードがあるイメージです。
カメラ、ライト等などがあっても基本的には同じ考え方でいけるはずです。
また、当たり前ですが.obj由来のようなシーングラフではないメッシュも同じやり方でエクスポートできます。
transformなしのルートノードに直接meshがあるシーングラフと等価だからです。
全体の流れは
1. ID付与処理
2. トランスフォームノードの処理
3. ジオメトリノードの処理
4. マテリアルの処理
5. gltf処理/glb処理
になります。
次に各ステップをやや詳しく説明します。
なおgltf処理及びglb処理に関してはC++ならtinygltfという偉大なオープンソースソフトウェアを使えば終わりです。
tinygltfはgltfの読み書きができますが、あくまで関係データベースのようなデータの状態への読み込みやそれからの書き込みしかサポートしていません。
自前シーングラフとglTFの関係はアプリケーションやライブラリ毎に異なるので一番面倒くさいORマッパー的な処理はやはり各々各自でやる必要があります。修行ですね。
ID付与処理
メモリ上のシーングラフに含まれるオブジェクトのインスタンスに、glTFの世界用のID(0スタートのインデックス)を振ります。
インスタンスのアドレスに対してユニークなIDをふればいいだけです、そんなに難しくないですね。
C++ならstd::unordered_map, pythonならdictを使えばいいだけですね。
ノード、ジオメトリ、マテリアルなど属性ごとに異なるIDをふる必要があります。
image, texture, buffer Viewなどにまでこの段階でIDをふると煩雑になると思うのでそれらはあとのステップでIDをふったほうがいいと思います。
実はこのステップで本質的な問題はほぼ解決です。
トランスフォームノードの処理
- トランスフォームをjsonに追加
ジオメトリノードの処理
メッシュをプリミティブにバラしながら
- Veretex Attributesをバイナリ変換
- Indicesをバイナリ変換
- meshにこれまでに生成された情報をセットしjsonに追加
- primitiveの情報(POSITION, NORMAL, UVなどのbufferView ID)
- indicesのbufferView ID
- material ID
(バイナリ変換については後述)
マテリアルの処理
- PBRパラメータをglTF仕様に変換し(真面目にやると難しいかも)jsonに追加
- textureのsourceにimage IDをセットしjsonに追加
- imageのmimeTypeに拡張子をセット、dataにエンコードされたデータをセットしjsonに追加
- image IDをインクリメント
gltf処理
- .gltf, .bin, 画像をそれぞれファイルに書く
glb処理
- 画像もバイナリ変換、ただしaccessorは不要(mimeTypeがあるため)
- jsonとバイナリにヘッダをつける、ただしそれぞれのヘッダ込みのバイトサイズが4の倍数になるようにパディングすること
- データ全体のヘッダをつけjsonとバイナリを連結し.glbに書く
バイナリ変換処理
- 生データをかためたバイナリをつくる
- bufferViewを作成
- バイナリのbyteLengthを計算
- buffer先頭からのbyteOffsetを計算
- accessorを作成
- bufferViewにbufferView IDをセット
- 対象に応じてcomponentType, count, typeを設定
- bufferView, accessorをjsonに追加、バイナリはバイナリの末尾に追加
- bufferView IDをインクリメント
終わりに
シーングラフをglTFに書くやり方をざっくりと説明しました。
ファイルは普通は書くより読む(パースする)ほうが難しいと思いますがglTFは書き込みも読み込みと同じくらい難しいという奇々怪々なフォーマットです。
インピーダンスミスマッチもAIとかディ~プラ~ニングとやらで解決してほしいですね、がんばってほしい。
自分用のメモのようなものですが、glTFをエクスポートしたくてたまらなくなってしまった修行僧の参考になればいいですね。