読者です 読者をやめる 読者になる 読者になる

alembic for realtime rendering


ここ数ヶ月仕事で Alembic を扱ってきたので、Alembic に関してここまで得られた知見を書き残しておこうと思います。
想定しているシチュエーションは主に、Alembic のライブラリを用いて alembic ファイル (.abc) を読み込み、そのデータを D3D11 などのグラフィックス API に流し込んで描画する、というようなものです。
Alembic はゲームに使うのは難しいと思われますが、例えば VR コンテンツには使い出がありますし、近年のゲームエンジンを使った映像制作でも重要な役割を担う可能性があります。また、Alembic は書き込みも簡単にできるため、プレイ中のゲームのシーンを Alembic で 3D 録画し、それを映像制作などに利用、といった応用も考えられます。

Alembic 概要

http://www.alembic.io/

過去にも軽く触れましたが、Alembic とは主に映像業界で使われているデータフォーマットです。ポリゴンメッシュなどを全フレームベイクして格納するのに用いられます。映像業界ではスキニングや物理シムは全てベイクして Alembic に格納し、レンダラやコンポジットソフトに渡す、といった使い方がなされます。
全フレームベイクして格納するわけですから、当然ながらゲーム屋からするとデータ量は桁外れにでかいものとなります。

Alembic はいろんなデータを時系列に沿って格納できるようになっています。データの各要素は property と呼ばれ、格納されているデータ群は sample と呼ばれます。ポリゴンモデルなどの具体的なオブジェクトは schema と呼ばれるオブジェクトに格納されています。この schema はいろんな property の集合体になっています。
例えばポリゴンモデルは PolyMesh という schema に格納されており、この PolyMesh は position、uv、index などの各種 property で構成されています。そして、時間 (またはインデックス) を指定すると、Alembic のライブラリはその時間/インデックスに対応する sample (PolyMesh の場合モデルデータ) を返す、といった具合に機能します。ちなみに Alembic は補間は行いません。

Alembic 導入

いきなりですが、自分的にこの導入が Alembic を使うにあたっての最大の難関だと思っています。
Alembic はバイナリの配布がされておらず、使うには自力でビルドする必要があります (関連する HDF や ILMBase も含めて)。
ビルドシステムが CMake なので、CMake で VisualStudio のプロジェクトを生成してビルドするだけ…、といきたいところですが、残念ながらそう簡単にはいきません。映像業界では Linux がデファクトスタンダードだそうで、映像関係のオープンソースソフトウェアは Windows はまともにサポートされてないことが多いです。Alembic も素直にはビルドできず、CMakeList とソース両方にいくつか手を加えてビルドを通るようにする必要がありました。
その詳細は面倒なので省略しますが、ここらへんにヘッダファイルと VisualStudio2015 用のビルド済みバイナリを置いています (容量削減のため .lib ファイル郡は .7z に圧縮済み)。とりあえず手を付けてみたいという方はこれの使用をおすすめします。

入門用の簡単なサンプルプログラムが欲しいのであれば、Alembic のソースに含まれるテストコードが参考になるでしょう。(これ とか同ディレクトリにある他のテストプログラム) 意外と簡単そうだというのが見て取れると思います。
より実践的な例が欲しいのであれば、拙作 AlembicImporter のソースが参考になるかもしれません。下の Schema の項目で触れる問題点に一通り対処してあります。Unity のプラグインとして使う前提で作ってはいますが、このプラグイン自身は Unity には非依存です。

Alembic の archive には HDF と Ogawa の 2 種類がありますが、HDF はレガシーなフォーマットです。自分で .abc を生成する場合は Ogawa を選んだほうがいいでしょう。こちらの方が速くて小さいことが多く、最近の DCC ツールでも大体 Ogawa がデフォルトになっています。
また、Alembic はエラーは例外を投げて通知してくることが多いです。ゲーム畑の人は面食らうかもしれません。私は面食らいました。

Schema

先に触れたように、ポリゴンモデルなどの具体的なデータは schema というオブジェクトが保持します。ここではいくつかの schema について使用の際の注意点などを挙げます。Xform, Camera, Points, PolyMesh の 4 種です。
schema は他にも何種類かありますが、Light はそれ自身にはライトに関連するパラメータは入っていないし、Material は抽象的すぎて対応が困難な上 DCC ツール側もろくに対応していない有様で、それ以外は Subdivision や NURBS といったもので今回は保留です。Material に関してはインポートの後独自対応が必要になりますが、現状映像業界でもそんな感じなようです。

  • Xform

Xform とは位置、回転、スケール などを表すもので、ゲームでは同等品は Transform と呼ばれることが多いと思います。Alembic では 1 つのオブジェクトが複数の schema を持つことはできないため、ほとんどのケースでは Xform の子として他の schema がぶら下がる形になっています。
例:

root - Xform - Camera
             |- Xform - PolyMesh

この Xform、Transform 相当品と書くと何ら難しいことはなさそうに見えますが、内部処理を理解していないとハマる可能性があります。
Xform は座標やスケール値などを直接保持するのではなく、各オペレーションおよびその順番を保持しています。オペレーションは 移動、任意軸回転、X/Y/Z 軸回転、スケールなどの種類があり、これらを任意の数、任意の順番で保持できます。そして、XformSample::getMatrix() を呼ぶとこのオペレーションを全て実行して結果を行列で返します。いろんな DCC ツールに対応するための苦慮が伺える実装です…。

問題は回転を取得したい場合です。XformSample には getAxis(), getAngle() というのが用意されているのでそれで簡単に取れるように見えますが、罠があります。getAxis() getAngle() は、getMatrix() で全オペレーションを実行して行列を算出した後、その行列からクオータニオンを抽出し、axis/angle に変換して返します。これは遅いだけでなく、回転とスケールが同時にかかっている場合に元と違う結果になってしまいます。正確な回転を取得したい場合、自力で全オペレーションを巡回して回転オペレーションだけを見て算出しなければなりません。
XformSample::getScale() も全く同じ問題を含んでおり、これも getMatrix() の結果からスケール値を抽出しようとするので、正確な値を取得したければ自力で全オペレーションを巡回して算出する必要があります。

  • Camera (ICamera, OCamera)

カメラの情報が入った schema です。含まれる情報は実にプリレンダ向けといった趣きで、焦点距離、撮影距離、口径、シャッター開始時間/終了時間 などなど、そういう感じです。
幸い、CameraSample には getNearClippingPlane(), getFarClippingPlane(), getFieldOfView() といったリアルタイムでも馴染み深いのもちゃんとあります。ただし、getFieldOfView() は横方向の FoV を返してきます。リアルタイムの場合必要なのは縦方向のそれです。残念ながらこれは自力で算出する必要があります。算出の処理はこうなります。

    verticalFoV: 2.0 * RadiansToDegrees( atan( verticalAperture * 10.0 / (2.0 * focalLength)));

ちなみにこれは getFieldOfView() の実装をコピペ改変しただけです。CameraSample は FoV そのものは保持しておらず、aperture と focal length から算出するようになっています。このため、逆に Alembic へカメラ情報をエクスポートしたい場合、FoV から aperture か focal length を算出する必要があります。AlembicImporter では aperture を固定して focal length を算出するようにしています。focal length 算出は以下のような処理になります。

    focalLength:  (aperture * 10.0) / tan(DegreesToRadians(fieldOfView / 2.0)) / 2.0;

本格的なレンズエフェクトを使う場合、その他の詳細なカメラの情報も必要になると思われますが、その際は単位に注意が必要です。focus distance や aperture は cm、focal length は mm といった具合に、メートルじゃない上に統一されていません。

  • PolyMesh (IPolyMesh, OPolyMesh)

ポリゴンメッシュの schema です。多くの場合これをレンダリングするのが Alembic をインポートする主目的になるでしょう。そして、やはりこやつもプリレンダ向けなデータ構造をしており、リアルタイム用途では扱いづらいものとなっています。障害になるのは主に次の 2 点だと思われます。

任意の n 角形を保持できる。混在もできる
3 角形だったり 4 角形だったり、100 角形である可能性すらなきにしもあらずで、それらが 1 つのモデルの中に混在しています。
何角形かという情報は IPolyMeshSchema::Sample::getFaceCounts() で取れるコンテナに保持されており、これが例えば [4,3,5] だった場合、index[0-3] が最初の 4 角系、index[4-6] が次の 3 角系、index[7-11] が最後の 5 角系、といった具合です。レンダリングの際は 3 角形化などの中間処理を入れることになるでしょう。

位置、法線、UV、それぞれ個別にインデックスを持てる
位置、法線、UV の要素数がそれぞれ異なる可能性があり、それぞれが個別にインデックスを持っている可能性があります。(インデックスの要素数は全て一致)
モデリングツールにおけるポリゴンの扱いを考えると、なるほど、という感じですが、レンダリングする側としては厄介です。インデックスが独立している場合、インデックスを展開するか、D3D11 世代以降の機能を利用して独自に頂点&インデックスバッファを用意して複数インデックスに対応させる必要があります。(インデックス展開 = index の要素数分の各要素の配列を用意して、for(int i=0; i<num_indices; ++i) { new_pos[i]=pos[index[i]]; } みたいに展開する)
前者はメモリ使用量が膨れ上がる上にたぶん速度も後者より劣りますが、既存のシェーダをそのまま使えます。一方後者は実行効率はいいものの頂点シェーダで特殊なことをやる必要があり、専用のシェーダを整備する必要が生じます。AlembicImporter では Unity 側で一般的なシェーダでレンダリングできるようにする必要があったため、インデックスを展開する方法を採っています。

他には、トポロジが変化する可能性があることもリアルタイム用途としてはやや特殊です。
IPolyMeshSchema::getTopologyVariance() でトポロジがどう変化するかという情報が取れます。これが kConstantTopology であれば頂点の位置もインデックスも一切変化しない static な mesh であり、kHomogenousTopology であれば頂点の位置は変化するもののインデックスは変化しない mesh であり、kHeterogenousTopology であれば頂点の位置もインデックスも変化する mesh です。
kHeterogenousTopology が一番取り扱いが面倒です。インデックスの内容だけでなく、インデックスや頂点の要素数も変化する可能性があります。事前に全サンプルを巡回して最大数を算出し、その分のバッファを割り当てておく、などの対応が必要になるかもしれません。

  • Points (IPoints, OPoints)

パーティクルなどを格納する schema です。sample が持つデータは位置、速度、ID、バウンディングボリュームだけで、リアルタイム用途でもほぼ中間処理なしで使えるでしょう。
ID はパーティクルの数が増減するときの識別用に使います。例えば位置や色をランダムにずらしたいような場合、パーティクルのインデックス (0-n) を乱数のシードにすると、パーティクルの増減に伴ってランダム値も変わってしまいます。こういう場合 ID を乱数のシードにすることで、パーティクルが増減してもランダム値に影響は出ないように改善できます。

Export

インポートができたらならエクスポートも簡単です。schema オブジェクトを作り、sample を内容を埋めて set するだけです。ただ、いくつか注意が必要な点もあります。

読み込み用のオブジェクト群と書き込み用のオブジェクト群ではなぜか class の関係が若干違っています。
読み込みの場合、IObject と schema class 群 (IXform など) は継承関係がなく、IObject が shcema を保持する形になっています。一方、OObject と schema class 群 (OXform など) は継承関係になっています。なので、読み込み用 class 群だけを見た場合 1 つのオブジェクトが複数の schema を持てるように見えますが、実際にはそういうケースはないと見ていいようです。

Alembic はデフォルトでは 1 sample / seconds (= 1 FPS) で記録するようになっています。これを変えるには TimeSampling を作り、各 schema にその TimeSampling のインデックスを与える必要があります。
TimeSampling とは、サンプルがどういう周期で記録されているかという情報です。これには 3 種類のモードがあります。

Uniform: 時間間隔が均一なモードです。開始時間とインターバルだけが記録され、n 番目のサンプルの時間は 開始時間 + インターバル * n になります。
Acyclic: 時間間隔が不均一なモードです。この場合全てのサンプルの時間がそのまま配列として記録されます。サンプル間には delta time が負になってはいけない以外に規則性はありません。
Cyclic: 時間間隔が不均一ながら周期的なモードです。インターバルとサンプル (配列) が記録されます。 例えばインターバルが 1.0、サンプルが [0.0, 0.1, 0.3] であれば、0.0, 0.1, 0.3, 1.0, 1.1, 1.3, ... という時間分布になります。

多くの場合は Uniform で事足りると思われますが、例えばゲームを 3D 録画したい場合は Acyclic にするとゲームへの影響を最小限にできるでしょう。
Cyclic は主にプリレンダにおけるモーションブラーのためのモードだそうで、シャッターオープン開始、シャッターフルオープン、シャッタークローズ開始、シャッターフルクローズ、のサイクルを繰り返し記録したいような時使うそうです。リアルタイムからは最も縁遠いモードと言えそうです。
TimeSampling は混在もでき、schema 単位はもちろん、property 単位で個別に設定できます。