【Houdini】1ジオメトリーから階層付きFBXを出力するツールの開発


この記事は[Houdini Advent Calendar 2020](https://qiita.com/advent-calendar/2020/houdini)の24日目の記事です。

#はじめに
Houdini標準のFBX出力機能で階層のあるFBXを出力する場合、基本的にはGeometryノードのネットワークで階層を表現しなければなりません。ただ、実際には１つのGeometryノードの中で作ったジオメトリーを**階層に分け、各階層に自由にTransformを設定した状態**で出力したい場面もあります。この問題を解決することを目指してHDAを作成しました。

[HDAとサンプルのダウンロード](https://drive.google.com/drive/folders/1aOLGyq_oAaPfTeC0oOMBritjhRnylps8?usp=sharing)

同じ？趣旨で既に、[大翔士さん](https://qiita.com/d658t)が「[Houdiniから階層とトランスフォームを保持してFBXを書き出すHDAの配布](https://qiita.com/d658t/items/d9ff6682595312e92ab9)」という記事で一つのアプローチが示されています。こちらの記事で配布されているHDAを元に（とはいえ大分違ったものになったが）作成しました。実装の足掛かりになりとても参考になりました、ありがとうございます。

#結果
テストとしてパンとチルトをするスポットライトを円形に並べた状態のジオメトリーを出力した結果がこちら。（パンとチルトは階層構造と自由なトランスフォームが実現できないとゲームエンジン側でスポットライトを良しなに動かせないのでテストとして使いました。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/131ff9bf-c0a6-2883-3c03-166643b4eed9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/73259b55-5f2b-c730-26c1-4bcb575ec12f.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/4fa3ddbf-c998-fda1-0133-551bd0b5a9e5.png)
詳しくは仕組みの解説で説明しますが、出力結果をざっと確認すると概ね正しいが、一部意図した結果になっていません。
Houdini上で`_`で区切られた文字列が階層名（フルパス）、各階層のローカル座標系の向きを三色の線でVisualizerで表示しています。
出力したFBXをUnityでシーンに置くと、階層分けとトランスフォームは正しく出力できていそうです。InspectorのRotationのxの40度は、Houdini上で40度チルトさせた回転が出力したFBXにも反映されているか確認するために入れた値でこれも正しく反映されていて、値を変えれば自由にライトの方向を操作できました。
ただ、階層名が意図したものになっていません。元々Houdni上で設定したいとしていた階層は以下のような階層です。


```正しい階層.
Lights
  |-SpotLight0
  |  |-Base
  |  |-Pan
  |    |...
  |
  |-SpotLight1
  |  |-Base
  |  |-Pan
  |    |...
  |
  |-SpotLight2
  |  |-Base
  |  |-Pan
  |    |...
  |
  |...
```

出力された階層は以下です。

```正しい階層.
Lights
  |-SpotLight0
  |  |-Base
  |  |-Pan
  |    |...
  |
  |-SpotLight1
  |  |-Base1
  |  |-Pan1
  |    |...
  |
  |-SpotLight2
  |  |-Base2
  |  |-Pan2
  |    |...
  |
  |...
```

ナンバリングに失敗していますね。他にも懸念点や改善の余地があります。それらにも触れつつ仕組みについて後述します。
(ただこの階層名のナンバリングの問題は条件付きですが解決できます。アドカレに実装が間に合いませんでした...すみません...)

# 使い方
1. Set Hierarchy SOPジでオメトリーに階層とトランスフォームのアトリビュートを追加する
2. 1を繰り返して任意の階層を作る
3. Export FBX SOPで出力

が最低限の作業です。（３つとも作成したHDAです。）
Mergeノードでジオメトリーを統合する際に入力ジオメトリーがそれぞれ階層を持っている場合に、正しく階層とトランスフォームのアトリビュートが統合されないのでその際はMerge Hierarchyを使用する必要がある。など、いくつかの場面で必要に応じて専用のノードを使用する必要があります。

## 例１　簡素な例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/494bbcd5-fd4e-e007-0fe1-23df285204d8.png)
この例の処理の流れは、

1. Boxジオメトリーに、(0,0,0)が原点、回転なし、スケール１の`Box`という名前のトランスフォームで階層を割り当てる。
2. BoxをY方向に2移動、Y軸に30度回転
3. Gridジオメトリーをマージ（Gridはまだ階層の情報を持っていない）
4. (0,0,0)が原点、回転なし、スケール１の`Root`という名前の階層を追加
5. FBX出力　(ボタンを押すと出力)
6. (デバッグ用に階層名と各階層の軸をVisualize)

です。

Set Hierarchyノードは、単に追加する階層名と階層のトランスフォームをパラメータで指定するだけです。この場合、Boxの中心に原点を置きたいので、`Translation Attribute Value`は(0,0,0)になっていますが、例えばBoxの底に原点を置きたければ(0,-0.5,0)にします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/ad1b48ae-b4d2-c47d-4eb3-a790f41676cd.png)
この場合階層は以下のようになります。

```例１階層.
Root  (Gridのメッシュを持っている)
|-Box (Boxのメッシュを持っている)
```

# 例２　階層を持つジオメトリーのマージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/f059cb86-65cf-6985-953c-f299a56588c1.png)


この例の処理の流れは、

1. 例１と同様にBoxに階層をセットしTransformノードで移動・回転
2. Gridにも(0,0,0)が原点、回転なし、スケール１の`Grid`という名前のトランスフォームで階層を追加
3. Gridジオメトリーを、**Merge Hierarchyノードで**マージ（GridもBoxも既に階層のアトリビュートを持っているので。）
4. 全体を少し上へ移動
5. (0,0,0)が原点、回転なし、スケール１の`Root`という名前のトランスフォームで階層を追加
6. FBX出力　(ボタンを押すと出力)
7. (デバッグ用に階層名と各階層の軸をVisualize)

です。

階層の情報はprimitiveとdetailのアトリビュートで管理しています。detailアトリビュートに`matrixlist`という階層名がすべて格納されたstringリストがあります。2の時点でBoxとGridはそれぞれが異なった値をもっています。標準のMergeノードをでマージすると同名のdetailアトリビュートがある場合、最後の入力のジオメトリーの値が出力ジオメトリーに引き継がれるようです。仕組み上`matrixlist`はすべての入力の値を連結したリストが出力される必要があるので、これを解決したMerge Hierarchyノードを使う必要があります。なので階層を持ったジオメトリー同士をマージする際はMerge Hierarchyを使う必要があります。

この場合階層は以下のようになります。

```例２階層.
Root    (メッシュを持たない空の階層)
|-Box   (Boxのメッシュを持っている)
|-Grid  (Gridのメッシュを持っている)
```

#例３　階層のコピー
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/1cd1e4cd-0c8a-a6d9-d692-00c04ee11feb.png)

この例の処理の流れは、

1. pigheadに(0,0,0)が原点、回転なし、スケール１の`Pig`という名前のトランスフォームで階層を追加
2. 9角形のポリゴンを作成し、@Nをポリゴンの中心を向くように設定
3. Copy to Points Hierarchyでpigheadを複製
4. (0,0,0)が原点、回転なし、スケール１の`Pigs`という名前のトランスフォームで階層を追加
5. FBX出力　(ボタンを押すと出力)
6. (デバッグ用に階層名と各階層の軸をVisualize)

です。

最終的に各階層名の末尾に0から8の数字が追加されています。これは３の段階で`Pig`階層が９つに複製された際にそれぞれを個別の階層として分ける処理が入ったからです。そのあとに`Pigs`階層が使いされたため`Pigs_pig数字`という階層になっています。この複製についてもMergeノードと同様に複製後に個体ごとに階層を分ける処理を追加する必要があったため、標準のCopy to PointsノードではなくCopy to Points Hierarchyノードを使う必要があります。現状ではCopy to Points向けのノードしかないですが、Duplicateや他のCopy系のノードでも同じような処理を追加したノードを新しく作る必要があります...
とにかく、幾つかの場面では既存のを階層を維持するためのノードに置き換えるコストがかかってしまう欠点があります...

#制約事項
Houdiniのノード名の制約、アトリビュート名の制約に依存して階層名として使える文字も制約を受けます。

 - 英数字のみ
 - 頭文字は英字のみ
  
を満たす文字列しか階層名に使用できません。

本当は、Houdiniから受ける制約だけを考えるならアンダースコア(`_`)も使えるのですが、`_`は階層の区切りを表す文字として使っているので階層名自体には使えません。（使えるように対応することもできはするのでいつか対応するかもしれません。）
既知のバグとして、前述した出力後のFBXのナンバリングのバグがあります。（これも条件付きで修正できるのでいつか対応するかもしれません。）

#仕組み
## 方針とポイント
当然ですが、この仕組みを導入してもなるべくこれまでのワークフローを邪魔しない且つ新しく考慮しないとならない要素を極力無くすことを目指します。具体的には、一度ある階層をアトリビュートに追加したらそれ以降はその階層について考える必要が無い状態にしたり、必要なノードの数を数個程度に抑えられたら嬉しい（と思っていました）。
### アトリビュートタイプ
以前かなり簡易に階層のあるFBXを出力する仕組みを作成した際、ジオメトリーが変形するなどしりネットワークを編集した際にそのたびに階層のトランスフォームもそれに合わせて修正する必要がありノードネットワークを細かく修正する必要がありました。まずはこれを解消する為には[アトリビュートタイプ(typeinfo)](https://www.sidefx.com/ja/docs/houdini/vex/attribtypeinfo_suite)を利用することを考えました。 [（アトリビュートタイプについての参考記事）](http://nomoreretake.net/2018/05/05/houdinitransformsop/)
公式のドキュメントではアトリビュートタイプについてこう説明されています。
> Houdiniのジオメトリアトリビュートは、そのアトリビュート内のデータが何かしらの種類のトランスフォーム(例えば、位置や回転)を表現することを示したメタデータを持つことができます。 そのメタデータがあると、ジオメトリ自体がトランスフォームされた時にそのデータを変更するのかどうか、または、どのように変更するのかが決まります。

Transformノードでジオメトリーを回転させた時、各pointの座標(P)が更新されますが同時に法線(N)も適切に修正されます。このようにジオメトリーのトランスフォームが変化した際に特定のアトリビュートタイプが設定されたアトリビュートはそのタイプの種類によって適切に値が更新されるというものです。これはTransformノードに限らず、さまざまなノードで適切な値の更新をしてくれます。
前述の使用例でも確認できるように、一度階層のトランスフォームを設定すると以降のTransformノードなどによる変形は随時各階層のトランスフォームにも反映されていました。これは階層のトランスフォームを表現した4*4行列のアトリビュートに`matrix`というアトリビュートタイプをセットしたことで受けられる恩恵です。

### アトリビュートをDetailで持つかPointで持つか
結論をいうとどちらにしても一長一短なので、目指す機能次第もしくは何を諦めるか次第だと思います。pointに持たせた場合は、メッシュを持たない空の階層を表現できないという問題があります。各pointが属する階層とその階層のトランスフォームの行列を保持した場合、空の階層には一つもpointは存在しえないのでそもそも空の階層を表現したpointを持つことができないということになります。ただ、各階層のトランスフォームを表現するpointは1つだけにして、ジオメトリーと階層のトランスフォームを表現するpointはグループで分けてしまって後の処理で上手いこと空の階層も表現することはできると思いますし、空の階層の情報だけはDetailに持たせるというアプローチもやろうと思えばできると思います。目的のジオメトリー以外の不要なコンポーネントを追加したくないという理由と、同じ情報をpointとdeitalに分散させるのは直感的じゃないという理由でどちらもなしと判断しました。
なので結局はdetailに諸々の情報を持たせました。空の階層を扱いやすく、pointのGeometrySpreadSheetを荒らさずに済む、不要なコンポーネントをジオメトリーに含まなくて済むというメリットがあるからです。

#### detailにトランスフォームの情報を持たせた時の欠点
この判断をした段階ではdetailに情報を詰め込むのアプローチのデメリットは、detailのGeometrySpreadSheetがめちゃくちゃに氾濫するくらいだと思っていましたが、実はアトリビュートタイプ回りの振る舞いで欠点がありました...
アトリビュートタイプが`matrix`な行列のdetailアトリビュートはちゃんとTransform時に値が更新されるかをテストした際に、Transformノードの変化はちゃんと反映されるのにたいして、Copy to Pointsでの変化はまさかの反映されないという結果になりました。ただ、detailではなくてpointに含まれる`matrix`な行列のアトリビュートはCopy to Pointsでの変化が反映されるのでここでpointにトランスフォームの情報を持たせるメリットが生まれました...
最終的にはCopy to PointsをラップしたHDAを作ってdetailの`matrix`なアトリビュートも値が更新されるようにしたものを用意することで解決しました。これは当初の必要なノードの数をなるべく少なくする指針に反しているので厳しいトレードオフですね。きっとまだ見つかっていないだけで他のノードでも似たような対応が必要になることを考えるとノードの総数は大分多くなる可能性もあってFBXを書き出すだけにしては重いツールになってしまいそうです。

### 一部のノードの作り
最終的に現時点では、６つのノードでとりあえず個人的な用途は満たせそうなものができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/1f44e279-f3dc-4d25-de78-3f3c60ba5e09.png)
（ただし、前述したCopy系のノードへの対応であと２，３個増えることは当然あり得えますね。）

#### Set Hierarchyノード
パラメータは

 - 追加する階層の名前
 - 階層のトランスフォーム値
 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/032d3fb5-1ced-04e6-2ea5-e7fbd9181bcb.png)

内部のネットワークは、

1. primアトリビュートの既存の階層名に新階層名を連結。階層名がなければ新階層名をそのまま値として代入。
2. detailアトリビュートの階層名一覧のリストにある階層名にも新階層名を連結し、新階層名を新しい階層としてappend
3. detailアトリビュートに新階層のトランスフォーム用の行列のアトリビュートを追加
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/559220fa-d470-614f-5c07-960fc7cc219e.png)

ここまで言及しませんでしたが、primにも階層名のアトリビュートを持たせています。これはFBXとして出力する際にジオメトリー複製し、各階層ごとに属するメッシュ（prim）を抽出する(他の階層のprimをdeleteする)ために、各primがどの階層に属するポリゴンなのかを分かるように必要があるからです。
例えば例２の場合は、最終的にdetailアトリビュートはこのようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/be2dcbc5-6803-3d1c-6811-c2a1562830a9.png)
`matrixlist`が階層名一覧で、各階層と同名のアトリビュートが存在し、アトリビュートタイプが`matrix`な4*4行列のアトリビュートです。階層が増えた部にdetailのGeometrySpreadSheetが16行増えていきます。

#### Export FBXノード（SOP/ROP）
これは前述した、[大翔士さん](https://qiita.com/d658t)の[記事](https://qiita.com/d658t/items/d9ff6682595312e92ab9)のHDAから学び大まかな構造は踏襲させて頂きました。
Export FBXノード内にあるObjnet内に以下のようなGeometryノードのツリーを構築しObjnetごとFBXとして出力することで階層化されたFBXを出力しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173366/c217c024-15fe-4bbe-83d9-f4a30578f634.png)

処理の流れは以下のようになっています。

1. FBX出力するジオメトリーから階層をVisualizeするために追加されたpointがあればそれらを削除する
2. detailの`matrixlist`アトリビュートから全階層名を取得し、それをそれらをアトリビュート名としてトランスフォームの行列を取得
3. この時点で階層ごとにExport FBXノード内にあるObjnet内にGeometryノードを作成する。階層名を"`_`"でsplitしたときの末尾の文字列をノード名にする ("Root"なら"Root"、"Root_box"なら"box"といった感じ。)
4. 各Geometryノードにトランスフォームの行列をsetWorldTransformする。（これで指定した位置/向き/スケールでオブジェクトを出力できる）
5. 各Geometryノード内に、ObjectMergeノードを作成し出力したいジオメトリーを引っ張ってくる。この時グループのパラメータを`@hierarchy = 階層名`にすることでその階層に属するprimだけがジオメトリーとして存在するようにしている。
6. ObjectMergeの次にTransformノードをつなぐ。GeometryノードにsetWorldTransformした分ジオメトリーが移動/回転/拡縮してしまうので、このTransformノードで逆行列のトランスフォームをすることで打ち消して元の位置にもどしている。
7. 階層名を"`_`"でsplitして得たリストから親の階層を求めてGeometryノードのinputをつなぎツリーを構築すれば完成

といった感じです。本当は、houdiniとunityの座標系のスケールの差を吸収する処理をも含まれていますが、その処理をオフにして出力した場合は上記の流れで出力されます。

ここまで説明してやっと最初のオブジェクトのナンバリングのバグの結果が分かります。同名のノードを同じパスの階層(この階層はhoudniのネットワークの階層)に同名のノードを作成すると自動的に末尾にナンバリングされてしまうことが原因でしょう。
なのでCopy to Points Hierarchyなどの複製をしたような階層がある場合はそれぞれをサブネットに入れることで自動ナンバリングを回避するこができそうです。しかしサブネットに入れるという事はアトリビュートで指定していない空の階層が生まれることにもなります。条件付きで解決できると書いたのはこの空の階層が強制的に発生してしまうからでした。

# おわり
pythonに触れたのも大翔士さんのHDAを拝見したのが初めてだったり、まだ見落としておりhoudniの機能や作法がある気がして部分的にでももう少しスマートにできそうな気がしています。もし何か心当たりある方いたらアドバイスいただけると嬉しいです！