Blenderの自作アドオン「Particle Tracker」の改善と Python API の解説

# はじめに
<!-- 発端や概要を記載 -->
本記事は前回の記事(https://qiita.com/Kai0731200/items/598f356d54b409dca66e) の続きです。
この度自作アドオン「Particle Tracker」を大幅にアップデートしました！
主な改善点としては
・キーフレーム方式によって使いやすさを大幅に向上
・複数パーティクルシステムへの対応
・ファイルを開き直しても不具合が起きないなど頑健性の向上
です。

今回は「Particle Tracker」の更新内容とソースコードの解説です。

アドオンのURL(https://github.com/Kai0731200/Particle-Tracker)

# 目次
<!-- タイトルとアンカー名を編集 -->
1. [前回の問題点と改善の方針](#前回の問題点と改善の方針)
1. [目標](#目標)
1. [準備](#準備)
1. [複数のパーティクルシステムへの対応](#複数のパーティクルシステムへの対応)
1. [Python APIでキーフレームを登録する](#Python_APIでキーフレームを登録する)
1. [初期位置が不安定になる現象の考察と対処法](#初期位置が不安定になる現象の考察と対処法)
1. [雨のシーンで使ってみる](#雨のシーンで使ってみる)
1. [まとめと考察](#まとめと考察)
1. [参考文献](#参考文献)


# 前回の問題点と改善の方針
<a id="#前回の問題点と改善の方針"></a>
前回はPython APIを用いてパーティクルの位置にポイントを作成して、handlerでパーティクルを追跡する方法を紹介しました。
しかしhandler方式にも問題点があります。

前回の実装では
**handlerを用いてフレームが変わるたびに、ポイントの位置をパーティクルの位置に再設定する**
という仕様になっていました。

handlerの問題点はその仕様の複雑さにあります。
handlerを複数解放して複数オブジェクトを操作することは可能ですが、handlerはプロジェクトに対して解放されるためオブジェクトを削除してもhandlerは残り続けます。そのため、handlerが残り続けると意図しない問題が起きたり、無駄な処理をフレームが変わるたびに走らせることになります。

またファイルを閉じるとhandlerは消えてしまい、次にファイルを開くとパーティクルの追跡ができなくなっているという問題があります。永続handlerを用いて回避するという方法もありますが、アドオンという形での実装すると他のプロジェクトでもhandlerが解放されてしまいます。

このように**handlerは仕様が複雑なため、ユーザはhandlerを意識しながら慎重に作業しなければなりませんし、実装に関しても考慮する要素が多いためコストがかかります。**

そこで**handlerではなくキーフレームを用いてパーティクルを追跡する**ことにしました。
キーフレームは各オブジェクトに設定されるためオブジェクトが複数あっても管理が簡単です。オブジェクトを削除してもhandlerのように処理が残るということはありませんし、ファイルを閉じてもキーフレームは削除されません。


今回は
**キーフレーム方式を用いたパーティクルの追跡と開発過程での発見**
について解説します。

この記事で紹介するコードは自作したアドオンで書かれたソースコードを説明のため一部改変したコードです。
また、ポイントの作成方法やパーティクルの位置の取得方法は前回の記事で詳しく解説しています。

完成したアドオン「Particle Tracker」のデモ動画
<iframe width="1280" height="675" src="https://www.youtube.com/embed/1Alczc3hjzY" title="Blender add-on “Particle Tracker” demo1 | Blender" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

<a id="#目標"></a>
# 目標
**キーフレーム方式を用いてパーティクルを追跡する**

<a id="#準備"></a>
# 準備
前回と同様にとりあえずパーティクルシステムを用意します。
立方体を発生源としてパーティークル数は10個、開始フレームは20、終了フレーム100に設定しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788547/bd492a11-9b51-e0ee-16d2-77d42d7b71ef.png)


<!-- 各チャプター -->
<a id="#複数のパーティクルシステムへの対応"></a>
# 複数のパーティクルシステムへの対応
前回handlerの仕様の複雑さのために複数のパーティクルシステムへの対応は断念していました。しかし、キーフレーム方式では複数のオブジェクトを作成しても管理が簡単なので今回実装することにしました。

サイドバーのUIからアクティブオブジェクトのパーティクルシステムを選択する下記のコード(一部抜粋)を解説していきます。

```python:_init_py
～前略～

def get_particle_system_names(self, context):
    obj = context.object
    particle_systems = obj.particle_systems
    particle_system_names = [(ps.name, ps.name, "") for ps in particle_systems]
    return particle_system_names

～中略～

def register():
    for c in classes:
        bpy.utils.register_class(c)
    bpy.types.Scene.selected_particle_system_name = bpy.props.EnumProperty(items=[], name="Selected Particle System Name")
    # パーティクルシステムの名前一覧を更新
    bpy.types.Scene.selected_particle_system_name = bpy.props.EnumProperty(
        items=get_particle_system_names,
        name="Selected Particle System Name"
    )
    
def unregister():
    for c in classes:
        bpy.utils.unregister_class(c)
    del bpy.types.Scene.selected_particle_system_name
```
まず、\_init\_.pyで**カスタムプロパティ「selected_particle_system_name」を追加**します。これは注目しているオブジェクトのパーティクルシステムの名前の一覧を持つenum型のプロパティです。
パーティクルシステムの名前の取得には、最初に定義した関数get_particle_system_namesを使っています。
シーンのカスタムプロパティとして「selected_particle_system_name」を登録するので他のファイルからでも問題なく参照できます。

```python:ParticleTracker_ui.py
#UIの構築
import bpy
from bpy.types import Panel

class ParticleTracker_PT_Panel(bpy.types.Panel): 
    bl_idname = "PT_PT_particle_tracker"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_label = "Particle Tracker"
    bl_category = 'Particle Tracker'

    def draw(self, context):
        layout = self.layout
        row = layout.row()
        row.label(text="Select Particle System to Track:")
        row = layout.row()
        row.prop(context.scene, "selected_particle_system_name", text="", icon='PARTICLE_DATA')
        row = layout.row()
        row.operator("object.particle_tracker_operator", text = "Create Particle Tracker")
```

ParticleTracker_ui.py部分では取得した名前一覧「selected_particle_system_name」から追跡したいパーティクルシステムをプルダウンで選択できるようにしています。
その下のコードでは、選択したパーティクルシステムについてポイントを作成、追跡するボタン「Create Particle Tracker」を配置しています。

```python:ParticleTracker_operator.py
import bpy
from bpy.types import Operator

def get_particle_system_index_by_name(obj, particle_system_name):
            particle_systems = obj.particle_systems
            for i, particle_system in enumerate(particle_systems):
                if particle_system.name == particle_system_name:
                    return i
            # 見つからない場合は -1 を返す
            return -1

class ParticleTracker_OT_Operator(Operator):
    bl_idname = "object.particle_tracker_operator"
    bl_label = "Track Particle System"
    bl_description = "Track Particles"
    
    def execute(self, context):
        # アクティブなオブジェクトを取得
        obj = context.active_object
        # アクティブなオブジェクトが存在するか確認
        if obj is None:
            self.report({'ERROR'}, "No active object found. Please select an object.")
            return {'CANCELLED'}
        selected_particle_system_name = context.scene.selected_particle_system_name
        # 選択したパーティクルシステムを取得
        selected_particle_system = obj.particle_systems.get(selected_particle_system_name)
        if selected_particle_system:
            # モディファイアが適用された状態にする
            particle_system_index=get_particle_system_index_by_name(obj, selected_particle_system_name)
            depsgraph = bpy.context.evaluated_depsgraph_get()
            modified = obj.evaluated_get(depsgraph)
            particle_system=modified.particle_systems[particle_system_index]
        else:
            self.report({'ERROR'}, "No particle system found in the active object.")
            return {'CANCELLED'}
```
ParticleTracker_operator.pyではUIから受け取った「selected_particle_system_name」をもとにパーティクルシステムを取得します。
前回と同様にevaluated_get(depsgraph)でモディファイアを適用した状態にして、パーティクルシステムの情報を取得しています。
get_particle_system_index_by_name関数は得られたパーティクルシステムの名前「selected_particle_system_name」をインデックスに変換する関数です。



<a id="#Python APIでキーフレームを登録する"></a>
# Python APIでキーフレームを登録する
いよいよ得られたパーティクルシステムの情報からポイントの作成と追跡をします。
上記のParticleTracker_operator.pyのソースコードの続きです。

```python
        ～前略～
        
        frame_start = int(particle_system.settings.frame_start)
        frame_end = int(particle_system.settings.frame_end)

        #パーティクルの位置を取得
        particles = particle_system.particles
        particle_list = [p.location for p in particles]

        # ポイントの作成
        edges = []
        faces = []
        plotDataPoint = bpy.data.meshes.new('plot_data')
        plotDataPoint.from_pydata(particle_list, edges, faces)

        # ポイントからオブジェクトを作成
        tracker_name = particle_system.name+'_Tracker'
        plotDataObject = bpy.data.objects.new(tracker_name, plotDataPoint)
        curScene = bpy.context.scene
        curScene.collection.objects.link(plotDataObject)

        #作成したオブジェクトの頂点情報
        vertices = plotDataPoint.vertices
        #キーフレームの間隔
        frame_interval = 1

        #目標の座標に移動する処理を繰り返す
        for frame_num in range (frame_start, frame_end+1, frame_interval):
            bpy.context.scene.frame_set(frame_num)
            bpy.context.view_layer.update() 
            for i in range(len(particles)):
                if particles[i].alive_state!="UNBORN":
                    vertices[i].co = particles[i].location
                    vertices[i].keyframe_insert(data_path = "co",index = -1)
        
        bpy.context.scene.frame_set(frame_start)
        self.report({'INFO'}, "Particle tracking completed successfully.")
        return {'FINISHED'}
```
まず前回と同様にパーティクルの位置情報からポイントを作成します。

次にそのポイントオブジェクトにキーフレームを毎フレーム登録します。
`frame_start`と`frame_end`はパーティクルの発生開始フレームと終了フレームであり、`frame_start`から`frame_end`の順に登録を行いました。
ここで **キーフレームに登録するのはオブジェクトの位置(location)ではなくオブジェクトの各頂点の位置(vertices[i].co)** です。
オブジェクトの位置(location)をキーフレームに登録すると原点を登録することになるので、全ポイントが全く同じ移動をすることになります。
なので今回は`vertices[i].keyframe_insert(data_path = "co",index = -1)`とすることで頂点の位置情報を登録します。
キーフレームの登録は


<a id="初期位置が不安定になる現象の考察と対処法"></a>
# 初期位置が不安定になる現象の考察と対処法
今回の実装の工夫点の一つに、ポイントのキーフレームを登録する際に条件
```python
if particles[i].alive_state!="UNBORN":
```
をつけるというのがあります。見ての通りparticlesのalive_stateが"UNBORN"以外ならキーフレームを登録するという条件です。
**実はこの条件がないとポイントを作成した際に各ポイントの初期位置が不安定になります。**
具体的に言うとパーティクルが発生開始するフレームでの各ポイントの位置が立方体内部ではなく全然別の場所になることがありました。

開始フレームで一部のポイントが立方体内部にない
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788547/cf7fbf1f-a1d8-8a2b-4b54-6a3d83f81d16.png)



この現象について様々なパターンで検証を行ってパーティクルシステムの内部のおおよその動きと対処法が分かったので解説します。
結論から言うと
**あるフレームでalive_state が‘UNBORN’ 以外だったパーティクルがそれより前のフレームに移動してalive_stateが‘UNBORN’になった場合、内部的にはパーティクルの位置更新が行われず前のフレームでの位置のままになっている**
というのが原因です。急に何の話だ？という感じなので順に説明します。

まずこの現象について色々検証したところ
開始フレーム以降のフレームを表示した後に`bpy.context.scene.frame_set(0)`で開始フレームに戻っても一部のパーティクルの位置が内部的に更新されない。
ということが分かりました。

ここでBlenderのパーティクルのPython APIを公式リファレンスで見てましょう！
Blenderの各パーティクルはPaticle(bpy_struct)というクラスを持っています。そのクラスのプロパティ(変数)の一つにそのパーティクルのライフサイクルの状態を表すalive_satateがあります。
alive_stateはenum型で４つの値
 **[‘DEAD’, ‘UNBORN’, ‘ALIVE’, ‘DYING’]**
 のいずれかをとります。
 パーティクルのライフサイクルは‘UNBORN’→‘ALIVE’→‘DYING’→‘DEAD’となります。

このalive_stateに関して条件を付けて様々なパターンを検証しました。
結果として
**あるフレームでalive_state が‘UNBORN’ 以外だったパーティクルがそれより前のフレームに移動してalive_stateが‘UNBORN’になった場合は内部的にはパーティクルの位置更新が行われない**
ということが分かりました。

`bpy.context.scene.frame_set(frame_start)`で開始フレームに移動してもパーティクルの位置が内部的に更新されず、前のフレームでの位置がキーフレームに登録されてしまっていたということです。

ではこの問題にどうやって対処するのか？
それは
**`if particles[i].alive_state!="UNBORN"`の条件をつけてキーフレームを登録して、パーティクル発生以前のポイントの位置をパーティクル発生時の位置に固定する**
という方法です！

一番最初のキーフレームより前のフレームでオブジェクトの位置がどうなっているのか簡単な例を見てみましょう。
・立方体を用意します。
・フレーム20で位置`(1, 0 , 0)`のキーフレームを登録します。
・フレーム20より前のフレームにはキーフレームは登録されていません。(フレーム20以降のフレームでは別の位置が登録されていてもよい)

ではフレーム0~19で立方体はどこにあるでしょうか？皆さんもお分かりの通り`(1, 0 , 0)`です。
![立方体.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788547/67ce27c4-df74-063d-37b4-ed7480ccd241.gif)



この性質を利用して**パーティクル発生前はキーフレームを登録せず、パーティクルが発生したときに最初のキーフレームを登録することで、パーティクル発生前のポイントの位置を固定する**というわけです。
そしてパーティクルが発生前か、発生しているかの情報をalive_stateが持っているので
`if particles[i].alive_state!="UNBORN"`の条件をつけるというわけです。

実行結果　立方体内にポイントが収まっている
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788547/365b824b-38a9-8e1d-9172-bab0f4b80e37.png)

頂点(vertices)ごとに登録されたキーフレーム
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788547/719fcad9-1582-f613-bbba-01194041df70.png)

これにて問題解決です！

<a id="#雨のシーンで使ってみる"></a>
# 雨のシーンで使ってみる
雨のシーンで使ってみます。前回と同様に最終的な全く同じです。
一旦キーフレームとして登録すればパーティクルシステムから切り離されるので応用は色々できそうです。
<iframe width="1280" height="675" src="https://www.youtube.com/embed/et39FMHpse0" title="Example of using Blender add-on “Particle Tracker” in a rain scene." frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

<a id="#まとめと考察"></a>
# まとめと考察
以上よりキーフレーム方式を用いたパーティクルの追跡ができるようになりました。
今回のアップデートで自分が目標としていたアドオンの機能を過不足なく実装できました。
何より使いやすさを向上させたのでぜひ多くの方に使って頂きたいです。


次回はパーティークルシステムそのものをジオメトリノードで再現する方法を解説します。
読んでいただきありがとうございます。

<a id="#参考文献"></a>
# 参考文献
Blender Python API 公式リファレンス  
https://docs.blender.org/api/current/bpy.types.ParticleSystem.html  
https://docs.blender.org/api/current/bpy.types.Particle.html