【Houdini】Script Solverで色々やる

# 0.  はじめに
HoudiniのScript Solver DOPについて調べたので、備忘録がてらまとめます．
質問や内容に関する指摘がありましたら、遠慮なくコメントとかください．


### 0-1. 動作環境
Red Hat Enterprise Linux 9.2
Houdini Education Edition Version 20.0 Build 547 Py39

### 0-2. 免責
この記事をもとに被った不利益について私は一切責任を負いません．

# 1.  Script Solverとは?

SideFXの[公式ドキュメント](https://www.sidefx.com/ja/docs/houdini/nodes/dop/scriptsolver.html)によると

>Script Solver DOPは、接続されたオブジェクトに対してSolveステップとしてHScriptコマンドを実行します。 通常では、このコマンドは、外部スクリプトファイルをソースとします。 このスクリプトは、どのHScriptコマンドも実行することができ、さらには、いくつかのコマンドは、Script Solver内からのみ実行することができます。
>
>(中略)
>
>Code Snippetモードは、Pythonを使用してSolveステージを定義することができます。 

らしいです．つまりDOPオブジェクトに対してHScriptなりPythonなりを使用して何かしらの処理が書けるわけです．
この記事では、Code SnippetモードでPythonを使用する場合のみを扱います．


### 1-1. Script Solverでもノードでもできること
- 新しいDOPオブジェクトを作成する
- シミュレーションからDOPオブジェクトを削除する
- DOPオブジェクトにサブデータを取り付ける
- DOPオブジェクトからサブデータを削除する
- リレーションシップを作成する
など．．．
### 1-2. Script Solverでしかできないこと
- シミュレーションのメタデータ（メモリ使用量など）を取得する
- 複数のDOPオブジェクト間でのデータのやり取り
- 複雑な条件をもとにリレーションシップを組む
など．．．
### 1-3. Script Solverでできないこと
ない（たぶんある）

# 2.  DOPオブジェクトに対する操作
の前に、まずサンプルのシーンを作ります．
下の画像のようにempty objectとscript solverをつなぐだけです．
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/790d5159-ef2c-88b1-1bfb-9a06a8dd74cd.png">

script solverの"Use Code Snippet"にチェックを入れるとPython Snippetがアクティブになるのでそのまま再生してみると、コンソールに文字が表示されると思います．

<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/e996f317-51b0-9dbd-2efb-10baad99ecef.png">

すでにコードの雛形がコメントアウトされているのでこれを参考に書いていけばよさそうですが長くなってしまうのでいったん全削除します．
```python
def solveForObjects(
        solver_data, new_dop_objects, existing_dop_objects, time, timestep):
    """Solve for the objects that use this solver.

    solver_data:
        The hou.DopData for the solver data created by the Python solver DOP
        node.  A shared instance of this data is attached to each of the
        objects being solved.  The parameter values from this node will have
        been copied into fields of the options record on this solver data.
        The solver_data is read-only while the solver is running.
    new_dop_objects:
        hou.DopObjects that have never been solved before by this solver.
        You may want to create new data on each of these objects.
    existing_dop_objects:
        hou.DopObjects that have been solved by this solver in previous
        timesteps.  You may want to update the data on each of these
        objects.
    time:
        The current simulation time.  This time may be different from the
        playbar's current time.
    timestep:
        The amount of simulation time since the solver was last invoked.
    """
    # Fields on the DOP data will correspond to parameters on the solver node.

    # ******** Put your code here ********
    print('solving new', new_dop_objects, 'existing', existing_dop_objects)

    #for dop_object in new_dop_objects:
    #    initial_position = solver_data.options().field('initialposition')
    #    sub_data = dop_object.createSubData('MySolverData', 'SIM_EmptyData')
    #    sub_data.options().setField('position', initial_position)
    #
    #for dop_object in existing_dop_objects:
    #    sub_data = dop_object.findSubData('MySolverData')
    #    position = sub_data.options().field('position')
    #    position += hou.Vector3(1, 2, 3)
    #    sub_data.options().setField('position', position)
    #
    #    with dop_object.editableGeometry() as geo:
    #        ...do something to modify the geometry...
```
scipt solverが処理された時点でこのsolveForObjectsという関数が実行され、solver_data, new_dop_objects, existing_dop_objects, time, timestepという引数が渡されます．
意味はそれぞれ

<dl>

  <dt>solver_data</dt>
  <dd>このソルバによって作られたソルバデータ</dd>
  <dd>hou.DopDataオブジェクト</dd>
  
  <dt>new_dop_objects</dt>
  <dd>このソルバによってまだ処理されたことのないDOPオブジェクト</dd>
  <dd>hou.DopObjectsオブジェクトのリスト</dd>
  
  <dt>existing_dop_objects</dt>
  <dd>これまでに処理されたことのあるDOPオブジェクト</dd>
  <dd>hou.DopObjectsオブジェクトのリスト</dd>
  
  <dt>time</dt>
  <dd>シミュレーションの現行時間</dd>
  
  <dt>timestep</dt>
  <dd>このソルバのタイムステップの長さ</dd>
  
</dl>

となっています．

### 2-1. サブデータの追加
早速上で作ったempty objectにサブデータを追加していきます．毎タイムステップ追加するわけにも行かないので今回は`new_dop_objects`を使用します．
まずは空のサブデータを作ります．
```python
def solveForObjects(solver_data, new_dop_objects, existing_dop_objects, time, timestep):

    for dop_object in new_dop_objects:
        #hou.DopObject.createSubData(サブデータ名: str, Dopデータタイプ名: str) -> hou.DopData
        dop_object.createSubData('MySolverData', 'SIM_EmptyData')
```
_cf._
[hou.DopObjectクラスのドキュメント](https://www.sidefx.com/ja/docs/houdini/hom/hou/DopObject.html)


シミュレーションをリクックしたあとジオメトリスプレッドシートを見ると、ちゃんとMySolverDataというサブデータが作られていることがわかります．


<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/21a26a39-ae2a-0604-847c-ac782e86130b.png">

では、ジオメトリのサブデータを作りたいときはどうすれば良いのでしょうか．
もう一度ジオメトリスプレッドシートをよく見ると、なにやらサブデータの`Basic`レコードには`datatype`なるフィールドがありそれが関係してそうです．
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/2b93b5c5-2c61-38a7-5d6b-e3f1d34efab6.png">
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/ea4965a4-e559-6fd8-2e65-e77f541ea382.png">
これをもとにコードを追加します．
```diff_python
def solveForObjects(solver_data, new_dop_objects, existing_dop_objects, time, timestep):

    for dop_object in new_dop_objects:
        #hou.DopObject.createSubData(サブデータ名: str, Dopデータタイプ名: str) -> hou.DopData
        dop_object.createSubData('MySolverData', 'SIM_EmptyData')
+       dop_object.createSubData('MyGeometryData', 'SIM_SopGeometry')
```

これで晴れていつもの光景を見ることができました．
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/86145dc2-859a-0186-9f6b-7c24f1830f4f.png">


##### 2-1-1. サブデータの種類
一応参考としてサブデータの種類を一部列挙します．
- SIM_EmptyData
- SIM_SopGeometry
- SIM_GeometryCopy
- SIM_SolverScript
- SIM_Object
- SIM_Relationship
- SIM_ColliderLabel
- SIM_PhysicalParms
など．．．

hou.DopDataTypeクラスのページが消えてる．．．
DOPデータタイプ名とhou.DopDataTypeインスタンスが一対一対応してないらしい



### 2-2. サブデータの修正
##### 2-2-1. ジオメトリサブデータを修正する
まずはサンプルシーンを作ります．ジオメトリがほしいのでsop geometryを使って適当にSOPsから持ってきます．
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/b5829663-7cf2-ec64-9694-51eca611f5d0.png">
テンプレートをチラ見しつつコードを書きます．
```python
def solveForObjects(solver_data, new_dop_objects, existing_dop_objects, time, timestep):
    
    for dop_object in new_dop_objects:
        #hou.DopObject.editableGeometry(name = サブデータ名: str) -> hou.EditableDopGeometryGuard or None
        with dop_object.editableGeometry(name = "Geometry") as geo:
            #初回だけ実行される
            #@Cdつくって初期化
            geo.addAttrib(hou.attribType.Point, "Cd", [0.0, 0.0, 0.0])
            
    for dop_object in existing_dop_objects:
        with dop_object.editableGeometry(name = "Geometry") as geo:
            #2回目以降、毎タイムステップ実行される
            #色を変更する(別にsolverでやる必要はない)
            for point in geo.points():
                color = [time % 3 / 2, time % 5 / 4, time % 7 / 6]
                point.setAttribValue("Cd", color)
```
再生するとジオメトリの色が変わっていくのがわかると思います．
テンプレートでは`editableGeometry()`に引数は渡してないですが与えといたほうがお行儀が良いので与えときます．

よく見ると、`with`ステートメント以下はPython SOPでやっていることと全く同じことに気がつくかと思います．
SOPsのPythonが使える方であればあとは何でもできますね．

数値計算させることもできますがマイクロソルバにやらせたほうが早いので、どちらかというとデータフローにおけるジオメトリに対するアドホックな処理みたいな使い方が正しい気がします．

##### 2-2-2. データを修正する
ジオメトリ以外のデータも修正してみたいと思います．
まず、script solverの上にgravityを追加してForces/Gravityというデータを追加します．
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/f09bfb1c-b506-bc31-9b08-249be3de78de.png">
そしてこの重力を10倍にしてみたいと思います．

```python
def solveForObjects(solver_data, new_dop_objects, existing_dop_objects, time, timestep):

    for dop_object in existing_dop_objects:
        #hou.DopObject.findSubData(サブデータ名もしくはパス: str) -> hou.DopData or None
        #サブデータの探索
        gravitySubData = dop_object.findSubData("Forces/Gravity_gravity1")

        #hou.DopData.options() -> hou.DopRecord
        #gravityデータのoptionsレコードを取得
        optionsRecord = gravitySubData.options()

        #hou.DopRecord.field(フィールド名: str) -> int , bool , float , str , hou.Vector2, hou.Vector3, hou.Vector4, hou.Quaternion, hou.Matrix3, or hou.Matrix4
        #forceフィールドの値を取得
        force = optionsRecord.field("force")

        #hou.DopRecord.setField(フィールド名: str, 値: int, float, str, hou.Vector2, hou.Vector3, hou.Vector4, hou.Quaternion, hou.Matrix3, hou.Matrix4) -> None
        #forceフィールドの値を更新
        optionsRecord.setField("force", force * 10)
```
_cf._
[hou.DopDataクラスのドキュメント](https://www.sidefx.com/ja/docs/houdini/hom/hou/DopData.html)
[hou.DopRecordクラスのドキュメント](https://www.sidefx.com/ja/docs/houdini/hom/hou/DopRecord.html)


ジオメトリスプレッドシートを見るとちゃんと値が更新されていることがわかります．
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/85d7637c-1d04-4662-2868-4bf7d9e1b487.png">


### 2-3. サブデータの削除
ではForcesというサブデータを消してみます．
```python
def solveForObjects(solver_data, new_dop_objects, existing_dop_objects, time, timestep):

    for dop_object in existing_dop_objects:
        #hou.DopObject.removeSubData(サブデータ名もしくはパス: str) -> None
        #Forcesサブデータを削除する
        dop_object.removeSubData("Forces")
```

無事消えました．
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/fbf7aa41-1653-b005-cc2e-f5f8aaed3fa7.png">

# 3.  シミュレーションオブジェクトに対する操作
まずはサンプルシーンを作ります．

empty objectにscript solverをつなぐだけです．
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/acdf0542-b91f-6143-202a-d8db7612569e.png">

### 3-1. 新しいDOPオブジェクトの作成
試しに1フレームに1つ新しいDOPオブジェクトを作ってみたいと思います．
以下のコードをscript solverに入れます．
```python
#solverForObject関数外のコードはdopnet上とscript solver上の2回実行される
#solverForObject関数内のコードはdopnet上で実行される

#script solver上で実行されているなら
#適切な名前に置き換えてください
if hou.pwd().name() == "scriptsolver1":

    #現行のシミュレーションオブジェクトを取得
    simObject = hou.node("./").simulation()
    
    #hou.DopSimulation.createObject(オブジ   ェクトの名前: str, 作られたフレームで処理するか: bool) -> hou.DopObject or None
    #新しいオブジェクトを作る
    objectName = "newObj" + str(simObject.time() * 24.0)
    newObject = simObject.createObject(name = objectName, solve_on_creation_frame = True)
    
    #Geometryサブデータを作る
    newObject.createSubData("Geometry", "SIM_SopGeometry")
    
def solveForObjects(solver_data, new_dop_objects, existing_dop_objects, time, timestep):
    pass
```
_cf._
[hou.DopSimulationクラスのドキュメント](https://www.sidefx.com/ja/docs/houdini/hom/hou/DopSimulation.html#relationships)
入力したあと、**Python SnippetのParameter Operationsを`Set Always`に**します．
ジオメトリスプレッドシートを見るときちんとDOPオブジェクトが作成されています．
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/84a90862-f648-b325-5327-faa60ae83f89.png">

コメントにも書きましたが、**solveForObject関数外**のコードは**script solver上とdopnet上の2回**、**関数内**のコードは**dopnet上の1回**実行される仕様みたいです．
地味にハマりました．

あとsolveForObject関数は**必須**です．
一見なくても動いたりしますが、古いコードがいるっぽいだけですのでHoudiniを起動し直すとエラーが出ます．



### 3-2. DOPオブジェクトの削除
試しに直近4フレーム以前に作られたDOPオブジェクトを削除してみます．
```diff_python
#solverForObject関数外のコードはdopnet上とscript solver上の2回実行される
#solverForObject関数内のコードはdopnet上で実行される

#script solver上で実行されているなら
#適切な名前に置き換えてください
if hou.pwd().name() == "scriptsolver1":

    #現行のシミュレーションオブジェクトを取得
    simObject = hou.node("./").simulation()
    
    #hou.DopSimulation.createObject(オブジ   ェクトの名前: str, 作られたフレームで処理するか: bool) -> hou.DopObject or None
    #新しいオブジェクトを作る
    objectName = "newObj" + str(simObject.time() * 24.0)
    newObject = simObject.createObject(name = objectName, solve_on_creation_frame = True)
    
    #Geometryサブデータを作る
    newObject.createSubData("Geometry", "SIM_SopGeometry")
    
+   #script solverのパス
+   thisNodePath = newObject.creator().path()


def solveForObjects(solver_data, new_dop_objects, existing_dop_objects, time, timestep):
-   pass
  
+   for object in existing_dop_objects:
+       #Basicレコードを取得
+       basicRecord = object.record("Basic")
+       
+       #creation timeフィールドの値を取得
+       creationTime = basicRecord.field("creationtime")
+       
+       #creatorフィールドの値を取得
+       creator = basicRecord.field("creator")
+      
+       #作られた時刻が現行から4フレーム前より小さく、このノードで作られたDOPオブジェクトなら削除する
+       referenceTime = simObject.time() - (4.0 * simObject.timestep())
+       
+       if (creationTime <= referenceTime) & (creator == thisNodePath):
+           simObject.removeObject(object)
```
ジオメトリスプレッドシートを見ると直近4フレーム以前にscript solverによって作られたDOPオブジェクトは削除されています．
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/e89ef433-6923-2ca2-18f0-d63a295af934.png">

たまに5ついるのどうにかしたい...

### 3-3. すべてのリレーションシップの削除
まずサンプルのシーンを作ります．
気分を変えてpop object3つをmergeにつないだ後script solverにつなぎます．
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/93c63d26-e761-f4b4-411a-765ffbfda1c8.png">

その後script solverの**Make Objects Mutual Affectors**のチェックを外しておきます．
これにチェックが入っていると、せっかく消したrelationshipがこのノードによってすべて互いに影響するようになってしまいます．
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/7cbe2c61-ac1a-cc93-7166-3d7caabaef8d.png">


ジオメトリスプレッドシートのAffector Matrixを見てみると、すべてのPOPオブジェクトが互いに影響を与えていることがわかると思います．
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565633/5a693936-4634-69f1-275d-9b9a12fc83fa.png">


これらのrelationshipを削除していきます．
```python
if hou.pwd().name() == "scriptsolver1":

    #現行のシミュレーションオブジェクトを取得
    simObject = hou.node("./").simulation()
    
    #hou.DopSimulation.relationships() -> hou.DopRelationship
    #すべてのrelationshipを取得
    rels = simObject.relationships()

    #hou.DopSimulation.removeRelationship(削除するrelationship: hou.DopRelationship) -> None
    #すべてのrelationshipを削除する
    [simObject.removeRelationship(rel) for rel in rels]
    
def solveForObjects(solver_data, new_dop_objects, existing_dop_objects, time, timestep):
    pass
```

ジオメトリスプレッドシートのAffectors Matrixを見てみるとすべてのrelationshipが削除されていることがわかると思います．
削除する`hou.DopRelationship`オブジェクトを条件でフィルタすれば任意のrelationshipを作成することができます．

### 3-4. リレーションシップの作成
wip

# 4.  実例

### 4-1. DOPオブジェクトの処理順を動的に変更する
wip


# 5.  おわり
wip